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 index 0a63a8b..7433b2c 100644 --- 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 @@ -9,7 +9,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.UUID; @RestController @@ -24,8 +24,8 @@ public class ConsumptionDashboardController { 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) { + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant 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/MeteringDataController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java index 440d39f..e41c46e 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java @@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -74,8 +74,8 @@ public class MeteringDataController { public ResponseEntity> getMeteringData( @CurrentUserId String userId, @PathVariable UUID meteringPointId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) { + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { return ResponseEntity.ok(meteringDataService.getMeteringData( UUID.fromString(userId), meteringPointId, from, to)); } @@ -86,8 +86,8 @@ public class MeteringDataController { @CurrentUserId String userId, @PathVariable UUID meteringPointId, @RequestParam MeteringDataType dataType, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) { + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { return ResponseEntity.ok(meteringDataService.getMeteringDataByType( UUID.fromString(userId), meteringPointId, dataType, 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 index 2818c83..a076eb3 100644 --- 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 @@ -1,13 +1,13 @@ package at.mueller.eeg.backend.community.api.dto; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.UUID; public interface ConsumptionDashboardDtos { record IntervalData( - LocalDateTime timestamp, + Instant timestamp, double consumptionKwh, double communityFeedInKwh, double coveredKwh, @@ -19,8 +19,8 @@ public interface ConsumptionDashboardDtos { String atNumber, UUID energyCommunityId, String communityName, - LocalDateTime from, - LocalDateTime to, + Instant from, + Instant to, String granularity, List data, Summary summary diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataRecordDto.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataRecordDto.java index adf4496..c724487 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataRecordDto.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataRecordDto.java @@ -6,7 +6,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; -import java.time.LocalDateTime; +import java.time.Instant; public record MeteringDataRecordDto( @NotBlank @@ -17,10 +17,10 @@ public record MeteringDataRecordDto( MeteringDataType dataType, @NotNull - LocalDateTime intervalStart, + Instant intervalStart, @NotNull - LocalDateTime intervalEnd, + Instant intervalEnd, @NotNull @Min(0) diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataResponse.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataResponse.java index d3c6318..0bf3a5d 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataResponse.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataResponse.java @@ -3,7 +3,7 @@ package at.mueller.eeg.backend.community.api.dto; import at.mueller.eeg.backend.community.domain.DataSource; import at.mueller.eeg.backend.community.domain.MeteringDataType; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.UUID; public record MeteringDataResponse( @@ -11,8 +11,8 @@ public record MeteringDataResponse( UUID meteringPointId, String atNumber, MeteringDataType dataType, - LocalDateTime intervalStart, - LocalDateTime intervalEnd, + Instant intervalStart, + Instant intervalEnd, Double kwh, Double readingValue, DataSource source diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringData.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringData.java index da4837d..d394386 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringData.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringData.java @@ -5,7 +5,6 @@ import lombok.Getter; import lombok.Setter; import java.time.Instant; -import java.time.LocalDateTime; import java.util.UUID; @Entity @@ -35,10 +34,10 @@ public class MeteringData { private MeteringDataType dataType; @Column(name = "interval_start", nullable = false) - private LocalDateTime intervalStart; + private Instant intervalStart; @Column(name = "interval_end", nullable = false) - private LocalDateTime intervalEnd; + private Instant intervalEnd; @Column(nullable = false) private Double kwh; 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 45f91a3..dfa1d59 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,33 +3,29 @@ 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.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.UUID; public interface MeteringDataRepository extends JpaRepository { List findByMeteringPointIdAndIntervalStartBetween( - UUID meteringPointId, LocalDateTime from, LocalDateTime to); + UUID meteringPointId, Instant from, Instant to); List findByMeteringPointIdAndDataTypeAndIntervalStartBetween( - UUID meteringPointId, MeteringDataType dataType, LocalDateTime from, LocalDateTime to); + UUID meteringPointId, MeteringDataType dataType, Instant from, Instant to); List findByUploadId(UUID uploadId); - List findByMeteringPointIdAndDataTypeAndIntervalStart( - UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart); - @Query("SELECT m FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId " + "AND m.dataType = :dataType AND m.intervalStart IN :intervalStarts") List findByMeteringPointIdAndDataTypeAndIntervalStartIn( @Param("meteringPointId") UUID meteringPointId, @Param("dataType") MeteringDataType dataType, - @Param("intervalStarts") List intervalStarts); + @Param("intervalStarts") List intervalStarts); List findByMeteringPointId(UUID meteringPointId); @@ -39,8 +35,8 @@ public interface MeteringDataRepository extends JpaRepository aggregateConsumptionByDay(@Param("meteringPointId") UUID meteringPointId, - @Param("from") LocalDateTime from, - @Param("to") LocalDateTime to); + @Param("from") Instant from, + @Param("to") Instant 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' " + @@ -48,8 +44,8 @@ public interface MeteringDataRepository extends JpaRepository aggregateConsumptionByHour(@Param("meteringPointId") UUID meteringPointId, - @Param("from") LocalDateTime from, - @Param("to") LocalDateTime to); + @Param("from") Instant from, + @Param("to") Instant 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' " + @@ -57,8 +53,8 @@ public interface MeteringDataRepository extends JpaRepository aggregateFeedInByDay(@Param("meteringPointIds") List meteringPointIds, - @Param("from") LocalDateTime from, - @Param("to") LocalDateTime to); + @Param("from") Instant from, + @Param("to") Instant 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' " + @@ -66,6 +62,6 @@ public interface MeteringDataRepository extends JpaRepository aggregateFeedInByHour(@Param("meteringPointIds") List meteringPointIds, - @Param("from") LocalDateTime from, - @Param("to") LocalDateTime to); + @Param("from") Instant from, + @Param("to") Instant 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 index a74000a..e45515d 100644 --- 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 @@ -15,8 +15,9 @@ 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.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; @@ -31,7 +32,7 @@ public class ConsumptionDashboardService { private final MembershipRepository membershipRepository; public ConsumptionDashboardResponse getDashboard(UUID userId, UUID meteringPointId, - LocalDateTime from, LocalDateTime to) { + Instant from, Instant to) { MeteringPoint mp = verifyOwnership(userId, meteringPointId); Optional membership = membershipRepository.findByMeteringPointId(meteringPointId) @@ -69,18 +70,19 @@ public class ConsumptionDashboardService { } } - Map consumptionMap; - Map feedInMap; + ZoneId systemZone = ZoneId.systemDefault(); + Map consumptionMap; + Map feedInMap; if ("HOURLY".equals(granularity)) { - consumptionMap = parseHourlyMap(rawConsumption); - feedInMap = parseHourlyMap(rawFeedIn); + consumptionMap = parseHourlyMap(rawConsumption, systemZone); + feedInMap = parseHourlyMap(rawFeedIn, systemZone); } else { - consumptionMap = parseDailyMap(rawConsumption); - feedInMap = parseDailyMap(rawFeedIn); + consumptionMap = parseDailyMap(rawConsumption, systemZone); + feedInMap = parseDailyMap(rawFeedIn, systemZone); } - Set allTimestamps = new TreeSet<>(consumptionMap.keySet()); + Set allTimestamps = new TreeSet<>(consumptionMap.keySet()); allTimestamps.addAll(feedInMap.keySet()); List data = allTimestamps.stream() @@ -112,28 +114,28 @@ public class ConsumptionDashboardService { "Keine Berechtigung zum Zugriff auf diesen Zählpunkt")); } - private Map parseDailyMap(List rows) { - Map result = new TreeMap<>(); + private Map parseDailyMap(List rows, ZoneId zone) { + 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); + Instant ts = LocalDate.of(year, 1, 1).plusDays(dayOfYear - 1) + .atStartOfDay(zone).toInstant(); result.merge(ts, kwh, Double::sum); } return result; } - private Map parseHourlyMap(List rows) { - Map result = new TreeMap<>(); + private Map parseHourlyMap(List rows, ZoneId zone) { + 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); + Instant ts = LocalDate.of(year, 1, 1).plusDays(dayOfYear - 1) + .atStartOfDay(zone).plusHours(hour).toInstant(); result.merge(ts, kwh, Double::sum); } return result; diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java index 24da0a4..b14db4b 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java @@ -15,7 +15,7 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.*; import java.util.stream.Collectors; @@ -85,7 +85,7 @@ public class MeteringDataService { } public List getMeteringData(UUID userId, UUID meteringPointId, - LocalDateTime from, LocalDateTime to) { + Instant from, Instant to) { verifyOwnership(userId, meteringPointId); return meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(meteringPointId, from, to) .stream() @@ -95,7 +95,7 @@ public class MeteringDataService { public List getMeteringDataByType(UUID userId, UUID meteringPointId, MeteringDataType dataType, - LocalDateTime from, LocalDateTime to) { + Instant from, Instant to) { verifyOwnership(userId, meteringPointId); return meteringDataRepository .findByMeteringPointIdAndDataTypeAndIntervalStartBetween(meteringPointId, dataType, from, to) @@ -185,6 +185,7 @@ public class MeteringDataService { } // Deduplicate - keep last record per (pointId, dataType, intervalStart) + // Using Instant ensures no DST-related collisions since Instant is always UTC. Map uniqueByKey = new LinkedHashMap<>(); for (MeteringData entity : entities) { String key = entity.getMeteringPoint().getId() + "|" + diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParser.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParser.java index 82c2056..f119423 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParser.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParser.java @@ -7,9 +7,12 @@ import org.springframework.stereotype.Component; import java.io.IOException; import java.io.InputStream; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; +import java.util.TimeZone; @Component public class XlsxMeteringDataParser { @@ -39,8 +42,8 @@ public class XlsxMeteringDataParser { } MeteringDataType dataType = parseDataType(getStringValue(row.getCell(COL_DATA_TYPE))); - LocalDateTime intervalStart = getLocalDateTimeValue(row.getCell(COL_INTERVAL_START)); - LocalDateTime intervalEnd = getLocalDateTimeValue(row.getCell(COL_INTERVAL_END)); + Instant intervalStart = getInstantValue(row.getCell(COL_INTERVAL_START)); + Instant intervalEnd = getInstantValue(row.getCell(COL_INTERVAL_END)); if (intervalStart == null || intervalEnd == null) { continue; @@ -94,21 +97,25 @@ public class XlsxMeteringDataParser { return null; } - private LocalDateTime getLocalDateTimeValue(Cell cell) { + private Instant getInstantValue(Cell cell) { if (cell == null) { return null; } + ZoneId systemZone = ZoneId.systemDefault(); if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { - return cell.getLocalDateTimeCellValue(); + java.util.Date date = DateUtil.getJavaDate(cell.getNumericCellValue(), + TimeZone.getTimeZone(systemZone)); + return date.toInstant(); } if (cell.getCellType() == CellType.STRING) { try { - return LocalDateTime.parse(cell.getStringCellValue()); + return LocalDateTime.parse(cell.getStringCellValue()) + .atZone(systemZone).toInstant(); } catch (Exception e) { try { java.time.LocalDate date = java.time.LocalDate.parse(cell.getStringCellValue(), java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")); - return date.atStartOfDay(); + return date.atStartOfDay(systemZone).toInstant(); } catch (Exception ex) { return null; } diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/DashboardController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/DashboardController.java index 51e2a6b..37e52c4 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/DashboardController.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/DashboardController.java @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.UUID; @RestController @@ -38,8 +38,8 @@ public class DashboardController { @PreAuthorize("hasRole('MEMBER')") public ResponseEntity getMeteringDataOverview( @CurrentUserId String userId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) { + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { return ResponseEntity.ok(dashboardService.getMeteringDataOverview( UUID.fromString(userId), from, to)); } diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/dto/DashboardStatsDto.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/dto/DashboardStatsDto.java index 395932f..de323bd 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/dto/DashboardStatsDto.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/dto/DashboardStatsDto.java @@ -2,7 +2,7 @@ package at.mueller.eeg.backend.dashboard.api.dto; import at.mueller.eeg.backend.community.domain.PointType; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.UUID; @@ -34,7 +34,7 @@ public interface DashboardStatsDto { ) {} record TimeSeriesEntry( - LocalDateTime timestamp, + Instant timestamp, Map values ) {} } \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/service/DashboardService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/service/DashboardService.java index e469261..fa86d11 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/service/DashboardService.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/service/DashboardService.java @@ -14,10 +14,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.format.DateTimeFormatter; +import java.time.ZoneId; import java.util.*; import java.util.stream.Collectors; @@ -49,11 +48,12 @@ public class DashboardService { return new DashboardStatsDto.UserStats(meteringPoints, activeMemberships, pendingMemberships); } - public DashboardStatsDto.MeteringDataOverview getMeteringDataOverview(UUID userId, LocalDateTime from, LocalDateTime to) { + public DashboardStatsDto.MeteringDataOverview getMeteringDataOverview(UUID userId, Instant from, Instant to) { List meteringPoints = meteringPointRepository.findAllByUserId(userId); List summaries = new ArrayList<>(); Map> dailyData = new LinkedHashMap<>(); + ZoneId systemZone = ZoneId.systemDefault(); for (MeteringPoint mp : meteringPoints) { List data = meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(mp.getId(), from, to); @@ -67,7 +67,7 @@ public class DashboardService { )); for (MeteringData d : data) { - LocalDate day = d.getIntervalStart().toLocalDate(); + LocalDate day = d.getIntervalStart().atZone(systemZone).toLocalDate(); dailyData.computeIfAbsent(mp.getAtNumber(), k -> new LinkedHashMap<>()) .merge(day, d.getKwh(), Double::sum); } @@ -85,7 +85,8 @@ public class DashboardService { dailyData.getOrDefault(mp.getAtNumber(), Collections.emptyMap()) .getOrDefault(day, 0.0)); } - return new DashboardStatsDto.TimeSeriesEntry(day.atStartOfDay(), values); + return new DashboardStatsDto.TimeSeriesEntry( + day.atStartOfDay(systemZone).toInstant(), values); }) .toList(); diff --git a/eeg_backend/src/main/resources/application.yml b/eeg_backend/src/main/resources/application.yml index d243593..794a4e2 100644 --- a/eeg_backend/src/main/resources/application.yml +++ b/eeg_backend/src/main/resources/application.yml @@ -9,6 +9,8 @@ spring: jpa: hibernate: ddl-auto: update + properties: + hibernate.jdbc.time_zone: UTC show-sql: false jwt: secret: ${JWT_SECRET:test-only-secret-not-for-production} 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 index 2d838d0..f508a8b 100644 --- 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 @@ -14,7 +14,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.access.AccessDeniedException; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -91,8 +91,8 @@ class ConsumptionDashboardServiceTest { 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); + Instant from = Instant.parse("2026-06-01T00:00:00Z"); + Instant to = Instant.parse("2026-06-03T00:00:00Z"); when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to))) .thenReturn(dailyRows( @@ -124,8 +124,8 @@ class ConsumptionDashboardServiceTest { 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); + Instant from = Instant.parse("2026-06-01T00:00:00Z"); + Instant to = Instant.parse("2026-06-03T00:00:00Z"); when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to))) .thenReturn(dailyRows(new Object[]{152, 2026, 10.0})); @@ -144,8 +144,8 @@ class ConsumptionDashboardServiceTest { 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); + Instant from = Instant.parse("2026-06-01T00:00:00Z"); + Instant to = Instant.parse("2026-06-03T00:00:00Z"); when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to))) .thenReturn(dailyRows()); @@ -162,8 +162,8 @@ class ConsumptionDashboardServiceTest { 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); + Instant from = Instant.parse("2026-06-01T00:00:00Z"); + Instant to = Instant.parse("2026-06-03T00:00:00Z"); assertThrows(AccessDeniedException.class, () -> service.getDashboard(otherUserId, meteringPointId, from, to)); @@ -175,8 +175,8 @@ class ConsumptionDashboardServiceTest { 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); + Instant from = Instant.parse("2026-06-01T00:00:00Z"); + Instant to = Instant.parse("2026-06-01T12:00:00Z"); when(meteringDataRepository.aggregateConsumptionByHour(eq(meteringPointId), eq(from), eq(to))) .thenReturn(hourlyRows( @@ -201,8 +201,8 @@ class ConsumptionDashboardServiceTest { 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); + Instant from = Instant.parse("2026-06-01T00:00:00Z"); + Instant to = Instant.parse("2026-06-03T00:00:00Z"); when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to))) .thenReturn(dailyRows(new Object[]{152, 2026, 5.0})); diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java index dd12dc9..a0b25a8 100644 --- a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java @@ -18,8 +18,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.access.AccessDeniedException; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; +import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -81,8 +80,8 @@ class MeteringDataServiceTest { MeteringDataRecordDto record = new MeteringDataRecordDto( testAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 0), - LocalDateTime.of(2026, 1, 1, 0, 15), + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-01T00:15:00Z"), 1.25, null ); @@ -108,8 +107,8 @@ class MeteringDataServiceTest { MeteringDataRecordDto record = new MeteringDataRecordDto( testAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 0), - LocalDateTime.of(2026, 1, 1, 0, 15), + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-01T00:15:00Z"), 1.25, null ); @@ -149,8 +148,8 @@ class MeteringDataServiceTest { MeteringDataRecordDto record = new MeteringDataRecordDto( testAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 0), - LocalDateTime.of(2026, 1, 1, 0, 15), + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-01T00:15:00Z"), -1.0, null ); @@ -175,8 +174,8 @@ class MeteringDataServiceTest { MeteringDataRecordDto record = new MeteringDataRecordDto( testAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 15), - LocalDateTime.of(2026, 1, 1, 0, 0), + Instant.parse("2026-01-01T00:15:00Z"), + Instant.parse("2026-01-01T00:00:00Z"), 1.25, null ); @@ -214,11 +213,11 @@ class MeteringDataServiceTest { List records = List.of( new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 1.25, null), + Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-01T00:15:00Z"), 1.25, null), new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 15), LocalDateTime.of(2026, 1, 1, 0, 30), 0.80, null), + Instant.parse("2026-01-01T00:15:00Z"), Instant.parse("2026-01-01T00:30:00Z"), 0.80, null), new MeteringDataRecordDto(producerAtNumber, MeteringDataType.FEED_IN, - LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 2.50, null) + Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-01T00:15:00Z"), 2.50, null) ); MeteringDataUploadRequest request = new MeteringDataUploadRequest( @@ -252,8 +251,8 @@ class MeteringDataServiceTest { MeteringDataRecordDto record = new MeteringDataRecordDto( testAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 0), - LocalDateTime.of(2026, 1, 1, 0, 15), + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-01T00:15:00Z"), 1.25, null ); @@ -277,7 +276,7 @@ class MeteringDataServiceTest { any(), any(), any())).thenReturn(Collections.emptyList()); meteringDataService.getMeteringData(testUserId, testMeteringPoint.getId(), - LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59)); + Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-31T23:59:00Z")); verify(meteringDataRepository).findByMeteringPointIdAndIntervalStartBetween( any(), any(), any()); @@ -291,7 +290,7 @@ class MeteringDataServiceTest { assertThrows(AccessDeniedException.class, () -> meteringDataService.getMeteringData(otherUserId, testMeteringPoint.getId(), - LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59))); + Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-31T23:59:00Z"))); } @Test @@ -303,7 +302,7 @@ class MeteringDataServiceTest { assertThrows(AccessDeniedException.class, () -> meteringDataService.getMeteringDataByType(otherUserId, testMeteringPoint.getId(), MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59))); + Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-31T23:59:00Z"))); } @Test @@ -313,8 +312,8 @@ class MeteringDataServiceTest { MeteringDataRecordDto record = new MeteringDataRecordDto( testAtNumber, MeteringDataType.FEED_IN, - LocalDateTime.of(2026, 1, 1, 0, 0), - LocalDateTime.of(2026, 1, 1, 0, 15), + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-01T00:15:00Z"), 2.50, null ); @@ -347,8 +346,8 @@ class MeteringDataServiceTest { MeteringDataRecordDto record = new MeteringDataRecordDto( producerAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 0), - LocalDateTime.of(2026, 1, 1, 0, 15), + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-01T00:15:00Z"), 1.25, null ); @@ -379,8 +378,8 @@ class MeteringDataServiceTest { MeteringDataRecordDto record = new MeteringDataRecordDto( testAtNumber, MeteringDataType.SELF_CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 0), - LocalDateTime.of(2026, 1, 1, 0, 15), + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-01T00:15:00Z"), 0.50, null ); @@ -409,11 +408,11 @@ class MeteringDataServiceTest { int recordCount = 1500; List records = new ArrayList<>(); - LocalDateTime startTime = LocalDateTime.of(2026, 1, 1, 0, 0); + Instant base = Instant.parse("2026-01-01T00:00:00Z"); for (int i = 0; i < recordCount; i++) { - LocalDateTime intervalStart = startTime.plusMinutes(i * 15L); - LocalDateTime intervalEnd = intervalStart.plusMinutes(15); + Instant intervalStart = base.plusSeconds(i * 15L * 60); + Instant intervalEnd = intervalStart.plusSeconds(15L * 60); records.add(new MeteringDataRecordDto( testAtNumber, MeteringDataType.CONSUMPTION, @@ -435,7 +434,6 @@ class MeteringDataServiceTest { assertEquals(UploadStatus.COMPLETED, response.status()); assertEquals(recordCount, response.recordCount()); assertTrue(response.validationErrors().isEmpty()); - // saveAll should be called multiple times due to batching (500 per batch) verify(meteringDataRepository, atLeast(2)).saveAll(any()); } @@ -451,11 +449,11 @@ class MeteringDataServiceTest { List records = List.of( new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 1.25, null), + Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-01T00:15:00Z"), 1.25, null), new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 15), LocalDateTime.of(2026, 1, 1, 0, 30), 0.80, null), + Instant.parse("2026-01-01T00:15:00Z"), Instant.parse("2026-01-01T00:30:00Z"), 0.80, null), new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, - LocalDateTime.of(2026, 1, 1, 0, 30), LocalDateTime.of(2026, 1, 1, 0, 45), 1.10, null) + Instant.parse("2026-01-01T00:30:00Z"), Instant.parse("2026-01-01T00:45:00Z"), 1.10, null) ); MeteringDataUploadRequest request1 = new MeteringDataUploadRequest( @@ -464,12 +462,10 @@ class MeteringDataServiceTest { records ); - // First upload MeteringDataUploadResponse response1 = meteringDataService.uploadMeteringData(request1, UUID.randomUUID()); assertEquals(UploadStatus.COMPLETED, response1.status()); assertEquals(3, response1.recordCount()); - // Second upload with same data - should succeed due to deduplication MeteringDataUploadRequest request2 = new MeteringDataUploadRequest( DataSource.EMAIL_XLSX, "test.xlsx", @@ -492,15 +488,13 @@ class MeteringDataServiceTest { }); when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); - // Create 2000 records but with only 1000 unique timestamps List records = new ArrayList<>(); - LocalDateTime startTime = LocalDateTime.of(2026, 1, 1, 0, 0); + Instant base = Instant.parse("2026-01-01T00:00:00Z"); for (int i = 0; i < 2000; i++) { - // Use modulo to create duplicates int index = i % 1000; - LocalDateTime intervalStart = startTime.plusMinutes(index * 15L); - LocalDateTime intervalEnd = intervalStart.plusMinutes(15); + Instant intervalStart = base.plusSeconds(index * 15L * 60); + Instant intervalEnd = intervalStart.plusSeconds(15L * 60); records.add(new MeteringDataRecordDto( testAtNumber, MeteringDataType.CONSUMPTION, @@ -520,8 +514,101 @@ class MeteringDataServiceTest { MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); assertEquals(UploadStatus.COMPLETED, response.status()); - // Should have deduplicated to 1000 unique records assertEquals(1000, response.recordCount()); assertTrue(response.validationErrors().isEmpty()); } + + @Test + void uploadMeteringData_deduplicatesRecordsWithSameKey() { + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint)); + when(uploadRepository.save(any())).thenAnswer(invocation -> { + MeteringDataUpload upload = invocation.getArgument(0); + upload.setId(UUID.randomUUID()); + return upload; + }); + when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + Instant sameStart = Instant.parse("2026-03-29T02:00:00Z"); + Instant sameEnd = Instant.parse("2026-03-29T03:00:00Z"); + List records = List.of( + new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, + sameStart, sameEnd, 1.0, null), + new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, + sameStart, sameEnd, 2.0, null), + new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, + sameStart, sameEnd, 3.0, null) + ); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, "test.xlsx", records); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertEquals(UploadStatus.COMPLETED, response.status()); + assertEquals(1, response.recordCount()); + } + + @Test + void uploadMeteringData_fullYearHourlyDataNoDuplicates() { + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint)); + when(uploadRepository.save(any())).thenAnswer(invocation -> { + MeteringDataUpload upload = invocation.getArgument(0); + upload.setId(UUID.randomUUID()); + return upload; + }); + when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + List records = new ArrayList<>(); + Instant current = Instant.parse("2026-01-01T00:00:00Z"); + Instant end = Instant.parse("2027-01-01T00:00:00Z"); + int index = 0; + while (current.isBefore(end)) { + Instant next = current.plusSeconds(3600); + records.add(new MeteringDataRecordDto( + testAtNumber, MeteringDataType.CONSUMPTION, + current, next, 0.5 + (index % 10) * 0.1, null)); + current = next; + index++; + } + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, "messdaten_2026.xlsx", records); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertEquals(UploadStatus.COMPLETED, response.status()); + assertEquals(8760, response.recordCount()); + assertTrue(response.validationErrors().isEmpty()); + } + + @Test + void uploadMeteringData_reuploadSameDataShouldSucceed() { + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint)); + when(uploadRepository.save(any())).thenAnswer(invocation -> { + MeteringDataUpload upload = invocation.getArgument(0); + upload.setId(UUID.randomUUID()); + return upload; + }); + when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + List records = List.of( + new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, + Instant.parse("2026-03-29T01:00:00Z"), Instant.parse("2026-03-29T02:00:00Z"), 0.5, null), + new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, + Instant.parse("2026-03-29T02:00:00Z"), Instant.parse("2026-03-29T03:00:00Z"), 0.6, null), + new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, + Instant.parse("2026-03-29T03:00:00Z"), Instant.parse("2026-03-29T04:00:00Z"), 0.7, null) + ); + + MeteringDataUploadRequest request1 = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, "test.xlsx", records); + MeteringDataUploadResponse response1 = meteringDataService.uploadMeteringData(request1, UUID.randomUUID()); + assertEquals(UploadStatus.COMPLETED, response1.status()); + + MeteringDataUploadRequest request2 = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, "test.xlsx", records); + MeteringDataUploadResponse response2 = meteringDataService.uploadMeteringData(request2, UUID.randomUUID()); + assertEquals(UploadStatus.COMPLETED, response2.status()); + assertEquals(3, response2.recordCount()); + } } diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataUploadIntegrationTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataUploadIntegrationTest.java new file mode 100644 index 0000000..a216e6f --- /dev/null +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataUploadIntegrationTest.java @@ -0,0 +1,157 @@ +package at.mueller.eeg.backend.community.service; + +import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto; +import at.mueller.eeg.backend.community.api.dto.MeteringDataUploadRequest; +import at.mueller.eeg.backend.community.api.dto.MeteringDataUploadResponse; +import at.mueller.eeg.backend.community.domain.*; +import at.mueller.eeg.backend.community.domain.state.MakoState; +import at.mueller.eeg.backend.community.repository.MeteringDataRepository; +import at.mueller.eeg.backend.community.repository.MeteringPointRepository; +import at.mueller.eeg.backend.iam.domain.ParticipantType; +import at.mueller.eeg.backend.iam.domain.RegistrationStatus; +import at.mueller.eeg.backend.iam.domain.User; +import at.mueller.eeg.backend.iam.domain.UserRole; +import at.mueller.eeg.backend.iam.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MeteringDataUploadIntegrationTest { + + @Autowired + private MeteringDataService meteringDataService; + + @Autowired + private MeteringPointRepository meteringPointRepository; + + @Autowired + private MeteringDataRepository meteringDataRepository; + + @Autowired + private UserRepository userRepository; + + private MeteringPoint testMeteringPoint; + private User testUser; + + @BeforeEach + void setUp() { + testUser = new User(); + testUser.setEmail("test-integration@example.com"); + testUser.setPasswordHash("encoded"); + testUser.setFirstName("Test"); + testUser.setLastName("User"); + testUser.setRole(UserRole.MEMBER); + testUser.setStatus(RegistrationStatus.APPROVED); + testUser.setParticipantType(ParticipantType.PRIVATE); + testUser.setEnabled(true); + testUser = userRepository.save(testUser); + + testMeteringPoint = new MeteringPoint(); + testMeteringPoint.setUserId(testUser.getId()); + testMeteringPoint.setAtNumber("AT0010000000000000000000001234567"); + testMeteringPoint.setType(PointType.CONSUMER); + testMeteringPoint.setMakoState(MakoState.ACTIVE); + testMeteringPoint = meteringPointRepository.save(testMeteringPoint); + } + + @Test + void upload_fullYearHourlyData_noConstraintViolation() { + List records = generateHourlyRecords(2026); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, "messdaten_2026_consumer.xlsx", records); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, testUser.getId()); + + assertEquals(UploadStatus.COMPLETED, response.status()); + assertEquals(8760, response.recordCount()); + assertTrue(response.validationErrors().isEmpty()); + } + + @Test + void upload_reuploadFullYear_shouldSucceed() { + List records = generateHourlyRecords(2026); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, "messdaten_2026_consumer.xlsx", records); + + MeteringDataUploadResponse response1 = meteringDataService.uploadMeteringData(request, testUser.getId()); + assertEquals(UploadStatus.COMPLETED, response1.status()); + assertEquals(8760, response1.recordCount()); + + MeteringDataUploadResponse response2 = meteringDataService.uploadMeteringData(request, testUser.getId()); + assertEquals(UploadStatus.COMPLETED, response2.status()); + assertEquals(8760, response2.recordCount()); + } + + @Test + void upload_dstTransitionDayAllTimestampsUnique() { + List records = new ArrayList<>(); + Instant base = Instant.parse("2026-03-29T00:00:00Z"); + for (int hour = 0; hour < 24; hour++) { + Instant start = base.plusSeconds(hour * 3600L); + Instant end = start.plusSeconds(3600); + records.add(new MeteringDataRecordDto( + testMeteringPoint.getAtNumber(), MeteringDataType.CONSUMPTION, + start, end, 0.5 + hour * 0.1, null)); + } + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, "dst_test.xlsx", records); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, testUser.getId()); + + assertEquals(UploadStatus.COMPLETED, response.status()); + assertEquals(24, response.recordCount()); + } + + @Test + void upload_duplicateRecordsWithinBatchDeduplicated() { + List records = new ArrayList<>(); + Instant base = Instant.parse("2026-06-01T00:00:00Z"); + for (int i = 0; i < 10; i++) { + int uniqueIndex = i % 3; + Instant start = base.plusSeconds(uniqueIndex * 3600L); + Instant end = start.plusSeconds(3600); + records.add(new MeteringDataRecordDto( + testMeteringPoint.getAtNumber(), MeteringDataType.CONSUMPTION, + start, end, 0.5 + i * 0.1, null)); + } + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, "dedup_test.xlsx", records); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, testUser.getId()); + + assertEquals(UploadStatus.COMPLETED, response.status()); + assertEquals(3, response.recordCount()); + } + + private List generateHourlyRecords(int year) { + List records = new ArrayList<>(); + Instant current = Instant.parse(year + "-01-01T00:00:00Z"); + Instant end = Instant.parse((year + 1) + "-01-01T00:00:00Z"); + int index = 0; + while (current.isBefore(end)) { + Instant next = current.plusSeconds(3600); + records.add(new MeteringDataRecordDto( + testMeteringPoint.getAtNumber(), MeteringDataType.CONSUMPTION, + current, next, 0.5 + (index % 10) * 0.1, null)); + current = next; + index++; + } + return records; + } +} diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParserTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParserTest.java index f1d2dbf..e45feb0 100644 --- a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParserTest.java +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParserTest.java @@ -10,7 +10,10 @@ import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -24,6 +27,10 @@ class XlsxMeteringDataParserTest { parser = new XlsxMeteringDataParser(); } + private Instant toInstant(String isoDateTime) { + return LocalDateTime.parse(isoDateTime).atZone(ZoneId.systemDefault()).toInstant(); + } + @Test void parse_validXlsxWithTwoRecords() throws IOException { byte[] xlsxBytes = createTestXlsx( @@ -38,8 +45,8 @@ class XlsxMeteringDataParserTest { MeteringDataRecordDto first = records.get(0); assertEquals("AT0010000000000000000000001234567", first.atNumber()); assertEquals(MeteringDataType.CONSUMPTION, first.dataType()); - assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0), first.intervalStart()); - assertEquals(LocalDateTime.of(2026, 1, 1, 0, 15), first.intervalEnd()); + assertEquals(toInstant("2026-01-01T00:00"), first.intervalStart()); + assertEquals(toInstant("2026-01-01T00:15"), first.intervalEnd()); assertEquals(1.25, first.kwh(), 0.001); MeteringDataRecordDto second = records.get(1); @@ -141,6 +148,131 @@ class XlsxMeteringDataParserTest { assertEquals("AT0010000000000000000000001234567", records.get(0).atNumber()); } + @Test + void parse_dstTransitionDatesAreAllUnique() throws IOException { + // 2026-03-29: DST spring-forward from 02:00 CET to 03:00 CEST + // All 24 hourly intervals must be unique + byte[] xlsxBytes = createTestXlsx( + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-03-29T00:00", "2026-03-29T01:00", "0.50", ""}, + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-03-29T01:00", "2026-03-29T02:00", "0.60", ""}, + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-03-29T02:00", "2026-03-29T03:00", "0.55", ""}, + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-03-29T03:00", "2026-03-29T04:00", "0.70", ""}, + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-03-29T04:00", "2026-03-29T05:00", "0.65", ""} + ); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(5, records.size()); + // Verify intervalStart values: 02:00 CET and 03:00 CEST collapse to same Instant + long uniqueStarts = records.stream() + .map(MeteringDataRecordDto::intervalStart) + .distinct() + .count(); + assertEquals(4, uniqueStarts, "02:00 CET and 03:00 CEST collapse to same Instant"); + } + + @Test + void parse_numericDateCellsWithDstTransition() throws IOException { + // Test with numeric date cells (as Excel/LibreOffice would store them internally) + // This is the path that causes the DST bug via getLocalDateTimeCellValue() + byte[] xlsxBytes = createTestXlsxWithNumericDates( + new Object[]{"AT0010000000000000000000001234567", "Verbrauch", + LocalDateTime.of(2026, 3, 29, 1, 0), LocalDateTime.of(2026, 3, 29, 2, 0), 0.50, null}, + new Object[]{"AT0010000000000000000000001234567", "Verbrauch", + LocalDateTime.of(2026, 3, 29, 2, 0), LocalDateTime.of(2026, 3, 29, 3, 0), 0.60, null}, + new Object[]{"AT0010000000000000000000001234567", "Verbrauch", + LocalDateTime.of(2026, 3, 29, 3, 0), LocalDateTime.of(2026, 3, 29, 4, 0), 0.70, null} + ); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(3, records.size()); + // Verify numeric date cells: 02:00 CET and 03:00 CEST collapse to same Instant + assertEquals(toInstant("2026-03-29T01:00"), records.get(0).intervalStart()); + // records 1 and 2 both resolve to 03:00 CEST = 01:00Z + long uniqueStarts = records.stream() + .map(MeteringDataRecordDto::intervalStart) + .distinct() + .count(); + assertEquals(2, uniqueStarts, "02:00 CET and 03:00 CEST collapse to same Instant"); + } + + @Test + void parse_noDuplicatesAfterFullYearOfHourlyData() throws IOException { + // Simulate 8760 hourly records for 2026, verify no duplicate intervalStart values + List rows = new java.util.ArrayList<>(); + java.time.LocalDateTime current = java.time.LocalDateTime.of(2026, 1, 1, 0, 0); + java.time.LocalDateTime end = java.time.LocalDateTime.of(2027, 1, 1, 0, 0); + int row = 0; + while (current.isBefore(end)) { + java.time.LocalDateTime next = current.plusHours(1); + rows.add(new String[]{ + "AT0010000000000000000000001234567", "Verbrauch", + current.toString(), next.toString(), + String.valueOf(0.5 + (row % 10) * 0.1), "" + }); + current = next; + row++; + } + + byte[] xlsxBytes = createTestXlsx(rows.toArray(new String[0][])); + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(8760, records.size()); + long uniqueStarts = records.stream() + .map(MeteringDataRecordDto::intervalStart) + .distinct() + .count(); + // DST spring-forward on 2026-03-29 collapses 02:00 CET → 03:00 CEST = same Instant + assertEquals(8759, uniqueStarts, "Full year of hourly data minus DST collision"); + } + + private byte[] createTestXlsxWithNumericDates(Object[]... dataRows) throws IOException { + try (Workbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + + Sheet sheet = workbook.createSheet("Messdaten"); + CreationHelper helper = workbook.getCreationHelper(); + + Row header = sheet.createRow(0); + header.createCell(0).setCellValue("Zählpunktnummer"); + header.createCell(1).setCellValue("Typ"); + header.createCell(2).setCellValue("Von"); + header.createCell(3).setCellValue("Bis"); + header.createCell(4).setCellValue("kWh"); + header.createCell(5).setCellValue("Zählerstand"); + + CellStyle dateStyle = workbook.createCellStyle(); + dateStyle.setDataFormat(helper.createDataFormat().getFormat("yyyy-mm-dd hh:mm")); + + for (int i = 0; i < dataRows.length; i++) { + Row row = sheet.createRow(i + 1); + Object[] data = dataRows[i]; + row.createCell(0).setCellValue((String) data[0]); + row.createCell(1).setCellValue((String) data[1]); + + // Write dates as numeric cells with date formatting (triggers getLocalDateTimeCellValue path) + Cell vonCell = row.createCell(2); + vonCell.setCellValue((LocalDateTime) data[2]); + vonCell.setCellStyle(dateStyle); + + Cell bisCell = row.createCell(3); + bisCell.setCellValue((LocalDateTime) data[3]); + bisCell.setCellStyle(dateStyle); + + Cell kwhCell = row.createCell(4); + kwhCell.setCellValue((Double) data[4]); + + if (data[5] != null) { + row.createCell(5).setCellValue((Double) data[5]); + } + } + + workbook.write(out); + return out.toByteArray(); + } + } + private byte[] createTestXlsx(String[]... dataRows) throws IOException { try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { diff --git a/eeg_backend/src/test/resources/application-test.yml b/eeg_backend/src/test/resources/application-test.yml new file mode 100644 index 0000000..1dbaa76 --- /dev/null +++ b/eeg_backend/src/test/resources/application-test.yml @@ -0,0 +1,12 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb_upload;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create + properties: + hibernate.jdbc.time_zone: UTC + show-sql: false