fix(community): migrate metering data timestamps from LocalDateTime to Instant to fix DST constraint violation
Switch interval_start/interval_end from LocalDateTime to Instant across the entire metering data stack (entity, DTOs, parser, repository, services, controllers). This eliminates DST-related unique constraint violations that occurred when two different wall-clock times mapped to the same epoch millis during CET/CEST transitions. Key changes: - MeteringData entity: intervalStart/intervalEnd now Instant - XlsxMeteringDataParser: produces Instant via JVM timezone conversion - MeteringDataRepository: all queries use Instant parameters - MeteringDataService: simplified deduplicate() (Instant has no DST ambiguity) - Added hibernate.jdbc.time_zone=UTC and test profile for in-memory H2 - Added MeteringDataUploadIntegrationTest with real H2 database - Added DST-specific parser and service tests - All 216 tests passing
This commit is contained in:
parent
cfc32b3a7d
commit
fb53b169a2
@ -9,7 +9,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -24,8 +24,8 @@ public class ConsumptionDashboardController {
|
|||||||
public ResponseEntity<ConsumptionDashboardResponse> getDashboard(
|
public ResponseEntity<ConsumptionDashboardResponse> getDashboard(
|
||||||
@CurrentUserId String userId,
|
@CurrentUserId String userId,
|
||||||
@PathVariable UUID meteringPointId,
|
@PathVariable UUID meteringPointId,
|
||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
|
||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
|
||||||
return ResponseEntity.ok(consumptionDashboardService.getDashboard(
|
return ResponseEntity.ok(consumptionDashboardService.getDashboard(
|
||||||
UUID.fromString(userId), meteringPointId, from, to));
|
UUID.fromString(userId), meteringPointId, from, to));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -74,8 +74,8 @@ public class MeteringDataController {
|
|||||||
public ResponseEntity<List<MeteringDataResponse>> getMeteringData(
|
public ResponseEntity<List<MeteringDataResponse>> getMeteringData(
|
||||||
@CurrentUserId String userId,
|
@CurrentUserId String userId,
|
||||||
@PathVariable UUID meteringPointId,
|
@PathVariable UUID meteringPointId,
|
||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
|
||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
|
||||||
return ResponseEntity.ok(meteringDataService.getMeteringData(
|
return ResponseEntity.ok(meteringDataService.getMeteringData(
|
||||||
UUID.fromString(userId), meteringPointId, from, to));
|
UUID.fromString(userId), meteringPointId, from, to));
|
||||||
}
|
}
|
||||||
@ -86,8 +86,8 @@ public class MeteringDataController {
|
|||||||
@CurrentUserId String userId,
|
@CurrentUserId String userId,
|
||||||
@PathVariable UUID meteringPointId,
|
@PathVariable UUID meteringPointId,
|
||||||
@RequestParam MeteringDataType dataType,
|
@RequestParam MeteringDataType dataType,
|
||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
|
||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
|
||||||
return ResponseEntity.ok(meteringDataService.getMeteringDataByType(
|
return ResponseEntity.ok(meteringDataService.getMeteringDataByType(
|
||||||
UUID.fromString(userId), meteringPointId, dataType, from, to));
|
UUID.fromString(userId), meteringPointId, dataType, from, to));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
package at.mueller.eeg.backend.community.api.dto;
|
package at.mueller.eeg.backend.community.api.dto;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface ConsumptionDashboardDtos {
|
public interface ConsumptionDashboardDtos {
|
||||||
|
|
||||||
record IntervalData(
|
record IntervalData(
|
||||||
LocalDateTime timestamp,
|
Instant timestamp,
|
||||||
double consumptionKwh,
|
double consumptionKwh,
|
||||||
double communityFeedInKwh,
|
double communityFeedInKwh,
|
||||||
double coveredKwh,
|
double coveredKwh,
|
||||||
@ -19,8 +19,8 @@ public interface ConsumptionDashboardDtos {
|
|||||||
String atNumber,
|
String atNumber,
|
||||||
UUID energyCommunityId,
|
UUID energyCommunityId,
|
||||||
String communityName,
|
String communityName,
|
||||||
LocalDateTime from,
|
Instant from,
|
||||||
LocalDateTime to,
|
Instant to,
|
||||||
String granularity,
|
String granularity,
|
||||||
List<IntervalData> data,
|
List<IntervalData> data,
|
||||||
Summary summary
|
Summary summary
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record MeteringDataRecordDto(
|
public record MeteringDataRecordDto(
|
||||||
@NotBlank
|
@NotBlank
|
||||||
@ -17,10 +17,10 @@ public record MeteringDataRecordDto(
|
|||||||
MeteringDataType dataType,
|
MeteringDataType dataType,
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
LocalDateTime intervalStart,
|
Instant intervalStart,
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
LocalDateTime intervalEnd,
|
Instant intervalEnd,
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Min(0)
|
@Min(0)
|
||||||
|
|||||||
@ -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.DataSource;
|
||||||
import at.mueller.eeg.backend.community.domain.MeteringDataType;
|
import at.mueller.eeg.backend.community.domain.MeteringDataType;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record MeteringDataResponse(
|
public record MeteringDataResponse(
|
||||||
@ -11,8 +11,8 @@ public record MeteringDataResponse(
|
|||||||
UUID meteringPointId,
|
UUID meteringPointId,
|
||||||
String atNumber,
|
String atNumber,
|
||||||
MeteringDataType dataType,
|
MeteringDataType dataType,
|
||||||
LocalDateTime intervalStart,
|
Instant intervalStart,
|
||||||
LocalDateTime intervalEnd,
|
Instant intervalEnd,
|
||||||
Double kwh,
|
Double kwh,
|
||||||
Double readingValue,
|
Double readingValue,
|
||||||
DataSource source
|
DataSource source
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import lombok.Getter;
|
|||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@ -35,10 +34,10 @@ public class MeteringData {
|
|||||||
private MeteringDataType dataType;
|
private MeteringDataType dataType;
|
||||||
|
|
||||||
@Column(name = "interval_start", nullable = false)
|
@Column(name = "interval_start", nullable = false)
|
||||||
private LocalDateTime intervalStart;
|
private Instant intervalStart;
|
||||||
|
|
||||||
@Column(name = "interval_end", nullable = false)
|
@Column(name = "interval_end", nullable = false)
|
||||||
private LocalDateTime intervalEnd;
|
private Instant intervalEnd;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Double kwh;
|
private Double kwh;
|
||||||
|
|||||||
@ -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.MeteringData;
|
||||||
import at.mueller.eeg.backend.community.domain.MeteringDataType;
|
import at.mueller.eeg.backend.community.domain.MeteringDataType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID> {
|
public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID> {
|
||||||
|
|
||||||
List<MeteringData> findByMeteringPointIdAndIntervalStartBetween(
|
List<MeteringData> findByMeteringPointIdAndIntervalStartBetween(
|
||||||
UUID meteringPointId, LocalDateTime from, LocalDateTime to);
|
UUID meteringPointId, Instant from, Instant to);
|
||||||
|
|
||||||
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartBetween(
|
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartBetween(
|
||||||
UUID meteringPointId, MeteringDataType dataType, LocalDateTime from, LocalDateTime to);
|
UUID meteringPointId, MeteringDataType dataType, Instant from, Instant to);
|
||||||
|
|
||||||
List<MeteringData> findByUploadId(UUID uploadId);
|
List<MeteringData> findByUploadId(UUID uploadId);
|
||||||
|
|
||||||
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStart(
|
|
||||||
UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart);
|
|
||||||
|
|
||||||
@Query("SELECT m FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId " +
|
@Query("SELECT m FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId " +
|
||||||
"AND m.dataType = :dataType AND m.intervalStart IN :intervalStarts")
|
"AND m.dataType = :dataType AND m.intervalStart IN :intervalStarts")
|
||||||
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartIn(
|
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartIn(
|
||||||
@Param("meteringPointId") UUID meteringPointId,
|
@Param("meteringPointId") UUID meteringPointId,
|
||||||
@Param("dataType") MeteringDataType dataType,
|
@Param("dataType") MeteringDataType dataType,
|
||||||
@Param("intervalStarts") List<LocalDateTime> intervalStarts);
|
@Param("intervalStarts") List<Instant> intervalStarts);
|
||||||
|
|
||||||
List<MeteringData> findByMeteringPointId(UUID meteringPointId);
|
List<MeteringData> findByMeteringPointId(UUID meteringPointId);
|
||||||
|
|
||||||
@ -39,8 +35,8 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
|
|||||||
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart) " +
|
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart) " +
|
||||||
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)")
|
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)")
|
||||||
List<Object[]> aggregateConsumptionByDay(@Param("meteringPointId") UUID meteringPointId,
|
List<Object[]> aggregateConsumptionByDay(@Param("meteringPointId") UUID meteringPointId,
|
||||||
@Param("from") LocalDateTime from,
|
@Param("from") Instant from,
|
||||||
@Param("to") LocalDateTime to);
|
@Param("to") Instant to);
|
||||||
|
|
||||||
@Query("SELECT FUNCTION('HOUR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
|
@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' " +
|
"FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId AND m.dataType = 'CONSUMPTION' " +
|
||||||
@ -48,8 +44,8 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
|
|||||||
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart) " +
|
"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)")
|
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart)")
|
||||||
List<Object[]> aggregateConsumptionByHour(@Param("meteringPointId") UUID meteringPointId,
|
List<Object[]> aggregateConsumptionByHour(@Param("meteringPointId") UUID meteringPointId,
|
||||||
@Param("from") LocalDateTime from,
|
@Param("from") Instant from,
|
||||||
@Param("to") LocalDateTime to);
|
@Param("to") Instant to);
|
||||||
|
|
||||||
@Query("SELECT FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
|
@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' " +
|
"FROM MeteringData m WHERE m.meteringPoint.id IN :meteringPointIds AND m.dataType = 'FEED_IN' " +
|
||||||
@ -57,8 +53,8 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
|
|||||||
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart) " +
|
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart) " +
|
||||||
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)")
|
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)")
|
||||||
List<Object[]> aggregateFeedInByDay(@Param("meteringPointIds") List<UUID> meteringPointIds,
|
List<Object[]> aggregateFeedInByDay(@Param("meteringPointIds") List<UUID> meteringPointIds,
|
||||||
@Param("from") LocalDateTime from,
|
@Param("from") Instant from,
|
||||||
@Param("to") LocalDateTime to);
|
@Param("to") Instant to);
|
||||||
|
|
||||||
@Query("SELECT FUNCTION('HOUR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
|
@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' " +
|
"FROM MeteringData m WHERE m.meteringPoint.id IN :meteringPointIds AND m.dataType = 'FEED_IN' " +
|
||||||
@ -66,6 +62,6 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
|
|||||||
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart) " +
|
"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)")
|
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart)")
|
||||||
List<Object[]> aggregateFeedInByHour(@Param("meteringPointIds") List<UUID> meteringPointIds,
|
List<Object[]> aggregateFeedInByHour(@Param("meteringPointIds") List<UUID> meteringPointIds,
|
||||||
@Param("from") LocalDateTime from,
|
@Param("from") Instant from,
|
||||||
@Param("to") LocalDateTime to);
|
@Param("to") Instant to);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,9 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@ -31,7 +32,7 @@ public class ConsumptionDashboardService {
|
|||||||
private final MembershipRepository membershipRepository;
|
private final MembershipRepository membershipRepository;
|
||||||
|
|
||||||
public ConsumptionDashboardResponse getDashboard(UUID userId, UUID meteringPointId,
|
public ConsumptionDashboardResponse getDashboard(UUID userId, UUID meteringPointId,
|
||||||
LocalDateTime from, LocalDateTime to) {
|
Instant from, Instant to) {
|
||||||
MeteringPoint mp = verifyOwnership(userId, meteringPointId);
|
MeteringPoint mp = verifyOwnership(userId, meteringPointId);
|
||||||
|
|
||||||
Optional<Membership> membership = membershipRepository.findByMeteringPointId(meteringPointId)
|
Optional<Membership> membership = membershipRepository.findByMeteringPointId(meteringPointId)
|
||||||
@ -69,18 +70,19 @@ public class ConsumptionDashboardService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<LocalDateTime, Double> consumptionMap;
|
ZoneId systemZone = ZoneId.systemDefault();
|
||||||
Map<LocalDateTime, Double> feedInMap;
|
Map<Instant, Double> consumptionMap;
|
||||||
|
Map<Instant, Double> feedInMap;
|
||||||
|
|
||||||
if ("HOURLY".equals(granularity)) {
|
if ("HOURLY".equals(granularity)) {
|
||||||
consumptionMap = parseHourlyMap(rawConsumption);
|
consumptionMap = parseHourlyMap(rawConsumption, systemZone);
|
||||||
feedInMap = parseHourlyMap(rawFeedIn);
|
feedInMap = parseHourlyMap(rawFeedIn, systemZone);
|
||||||
} else {
|
} else {
|
||||||
consumptionMap = parseDailyMap(rawConsumption);
|
consumptionMap = parseDailyMap(rawConsumption, systemZone);
|
||||||
feedInMap = parseDailyMap(rawFeedIn);
|
feedInMap = parseDailyMap(rawFeedIn, systemZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<LocalDateTime> allTimestamps = new TreeSet<>(consumptionMap.keySet());
|
Set<Instant> allTimestamps = new TreeSet<>(consumptionMap.keySet());
|
||||||
allTimestamps.addAll(feedInMap.keySet());
|
allTimestamps.addAll(feedInMap.keySet());
|
||||||
|
|
||||||
List<IntervalData> data = allTimestamps.stream()
|
List<IntervalData> data = allTimestamps.stream()
|
||||||
@ -112,28 +114,28 @@ public class ConsumptionDashboardService {
|
|||||||
"Keine Berechtigung zum Zugriff auf diesen Zählpunkt"));
|
"Keine Berechtigung zum Zugriff auf diesen Zählpunkt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<LocalDateTime, Double> parseDailyMap(List<Object[]> rows) {
|
private Map<Instant, Double> parseDailyMap(List<Object[]> rows, ZoneId zone) {
|
||||||
Map<LocalDateTime, Double> result = new TreeMap<>();
|
Map<Instant, Double> result = new TreeMap<>();
|
||||||
for (Object[] row : rows) {
|
for (Object[] row : rows) {
|
||||||
int dayOfYear = ((Number) row[0]).intValue();
|
int dayOfYear = ((Number) row[0]).intValue();
|
||||||
int year = ((Number) row[1]).intValue();
|
int year = ((Number) row[1]).intValue();
|
||||||
double kwh = ((Number) row[2]).doubleValue();
|
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);
|
result.merge(ts, kwh, Double::sum);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<LocalDateTime, Double> parseHourlyMap(List<Object[]> rows) {
|
private Map<Instant, Double> parseHourlyMap(List<Object[]> rows, ZoneId zone) {
|
||||||
Map<LocalDateTime, Double> result = new TreeMap<>();
|
Map<Instant, Double> result = new TreeMap<>();
|
||||||
for (Object[] row : rows) {
|
for (Object[] row : rows) {
|
||||||
int hour = ((Number) row[0]).intValue();
|
int hour = ((Number) row[0]).intValue();
|
||||||
int dayOfYear = ((Number) row[1]).intValue();
|
int dayOfYear = ((Number) row[1]).intValue();
|
||||||
int year = ((Number) row[2]).intValue();
|
int year = ((Number) row[2]).intValue();
|
||||||
double kwh = ((Number) row[3]).doubleValue();
|
double kwh = ((Number) row[3]).doubleValue();
|
||||||
LocalDateTime ts = LocalDateTime.of(year, 1, 1, 0, 0)
|
Instant ts = LocalDate.of(year, 1, 1).plusDays(dayOfYear - 1)
|
||||||
.plusDays(dayOfYear - 1)
|
.atStartOfDay(zone).plusHours(hour).toInstant();
|
||||||
.plusHours(hour);
|
|
||||||
result.merge(ts, kwh, Double::sum);
|
result.merge(ts, kwh, Double::sum);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import org.springframework.security.access.AccessDeniedException;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@ -85,7 +85,7 @@ public class MeteringDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<MeteringDataResponse> getMeteringData(UUID userId, UUID meteringPointId,
|
public List<MeteringDataResponse> getMeteringData(UUID userId, UUID meteringPointId,
|
||||||
LocalDateTime from, LocalDateTime to) {
|
Instant from, Instant to) {
|
||||||
verifyOwnership(userId, meteringPointId);
|
verifyOwnership(userId, meteringPointId);
|
||||||
return meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(meteringPointId, from, to)
|
return meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(meteringPointId, from, to)
|
||||||
.stream()
|
.stream()
|
||||||
@ -95,7 +95,7 @@ public class MeteringDataService {
|
|||||||
|
|
||||||
public List<MeteringDataResponse> getMeteringDataByType(UUID userId, UUID meteringPointId,
|
public List<MeteringDataResponse> getMeteringDataByType(UUID userId, UUID meteringPointId,
|
||||||
MeteringDataType dataType,
|
MeteringDataType dataType,
|
||||||
LocalDateTime from, LocalDateTime to) {
|
Instant from, Instant to) {
|
||||||
verifyOwnership(userId, meteringPointId);
|
verifyOwnership(userId, meteringPointId);
|
||||||
return meteringDataRepository
|
return meteringDataRepository
|
||||||
.findByMeteringPointIdAndDataTypeAndIntervalStartBetween(meteringPointId, dataType, from, to)
|
.findByMeteringPointIdAndDataTypeAndIntervalStartBetween(meteringPointId, dataType, from, to)
|
||||||
@ -185,6 +185,7 @@ public class MeteringDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate - keep last record per (pointId, dataType, intervalStart)
|
// Deduplicate - keep last record per (pointId, dataType, intervalStart)
|
||||||
|
// Using Instant ensures no DST-related collisions since Instant is always UTC.
|
||||||
Map<String, MeteringData> uniqueByKey = new LinkedHashMap<>();
|
Map<String, MeteringData> uniqueByKey = new LinkedHashMap<>();
|
||||||
for (MeteringData entity : entities) {
|
for (MeteringData entity : entities) {
|
||||||
String key = entity.getMeteringPoint().getId() + "|" +
|
String key = entity.getMeteringPoint().getId() + "|" +
|
||||||
|
|||||||
@ -7,9 +7,12 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class XlsxMeteringDataParser {
|
public class XlsxMeteringDataParser {
|
||||||
@ -39,8 +42,8 @@ public class XlsxMeteringDataParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MeteringDataType dataType = parseDataType(getStringValue(row.getCell(COL_DATA_TYPE)));
|
MeteringDataType dataType = parseDataType(getStringValue(row.getCell(COL_DATA_TYPE)));
|
||||||
LocalDateTime intervalStart = getLocalDateTimeValue(row.getCell(COL_INTERVAL_START));
|
Instant intervalStart = getInstantValue(row.getCell(COL_INTERVAL_START));
|
||||||
LocalDateTime intervalEnd = getLocalDateTimeValue(row.getCell(COL_INTERVAL_END));
|
Instant intervalEnd = getInstantValue(row.getCell(COL_INTERVAL_END));
|
||||||
|
|
||||||
if (intervalStart == null || intervalEnd == null) {
|
if (intervalStart == null || intervalEnd == null) {
|
||||||
continue;
|
continue;
|
||||||
@ -94,21 +97,25 @@ public class XlsxMeteringDataParser {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private LocalDateTime getLocalDateTimeValue(Cell cell) {
|
private Instant getInstantValue(Cell cell) {
|
||||||
if (cell == null) {
|
if (cell == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
ZoneId systemZone = ZoneId.systemDefault();
|
||||||
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
|
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) {
|
if (cell.getCellType() == CellType.STRING) {
|
||||||
try {
|
try {
|
||||||
return LocalDateTime.parse(cell.getStringCellValue());
|
return LocalDateTime.parse(cell.getStringCellValue())
|
||||||
|
.atZone(systemZone).toInstant();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
try {
|
try {
|
||||||
java.time.LocalDate date = java.time.LocalDate.parse(cell.getStringCellValue(),
|
java.time.LocalDate date = java.time.LocalDate.parse(cell.getStringCellValue(),
|
||||||
java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy"));
|
java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy"));
|
||||||
return date.atStartOfDay();
|
return date.atStartOfDay(systemZone).toInstant();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -38,8 +38,8 @@ public class DashboardController {
|
|||||||
@PreAuthorize("hasRole('MEMBER')")
|
@PreAuthorize("hasRole('MEMBER')")
|
||||||
public ResponseEntity<DashboardStatsDto.MeteringDataOverview> getMeteringDataOverview(
|
public ResponseEntity<DashboardStatsDto.MeteringDataOverview> getMeteringDataOverview(
|
||||||
@CurrentUserId String userId,
|
@CurrentUserId String userId,
|
||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
|
||||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
|
||||||
return ResponseEntity.ok(dashboardService.getMeteringDataOverview(
|
return ResponseEntity.ok(dashboardService.getMeteringDataOverview(
|
||||||
UUID.fromString(userId), from, to));
|
UUID.fromString(userId), from, to));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ package at.mueller.eeg.backend.dashboard.api.dto;
|
|||||||
|
|
||||||
import at.mueller.eeg.backend.community.domain.PointType;
|
import at.mueller.eeg.backend.community.domain.PointType;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@ -34,7 +34,7 @@ public interface DashboardStatsDto {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
record TimeSeriesEntry(
|
record TimeSeriesEntry(
|
||||||
LocalDateTime timestamp,
|
Instant timestamp,
|
||||||
Map<String, Double> values
|
Map<String, Double> values
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@ -14,10 +14,9 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.ZoneId;
|
||||||
import java.time.LocalTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@ -49,11 +48,12 @@ public class DashboardService {
|
|||||||
return new DashboardStatsDto.UserStats(meteringPoints, activeMemberships, pendingMemberships);
|
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<MeteringPoint> meteringPoints = meteringPointRepository.findAllByUserId(userId);
|
List<MeteringPoint> meteringPoints = meteringPointRepository.findAllByUserId(userId);
|
||||||
|
|
||||||
List<DashboardStatsDto.MeteringPointSummary> summaries = new ArrayList<>();
|
List<DashboardStatsDto.MeteringPointSummary> summaries = new ArrayList<>();
|
||||||
Map<String, Map<LocalDate, Double>> dailyData = new LinkedHashMap<>();
|
Map<String, Map<LocalDate, Double>> dailyData = new LinkedHashMap<>();
|
||||||
|
ZoneId systemZone = ZoneId.systemDefault();
|
||||||
|
|
||||||
for (MeteringPoint mp : meteringPoints) {
|
for (MeteringPoint mp : meteringPoints) {
|
||||||
List<MeteringData> data = meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(mp.getId(), from, to);
|
List<MeteringData> data = meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(mp.getId(), from, to);
|
||||||
@ -67,7 +67,7 @@ public class DashboardService {
|
|||||||
));
|
));
|
||||||
|
|
||||||
for (MeteringData d : data) {
|
for (MeteringData d : data) {
|
||||||
LocalDate day = d.getIntervalStart().toLocalDate();
|
LocalDate day = d.getIntervalStart().atZone(systemZone).toLocalDate();
|
||||||
dailyData.computeIfAbsent(mp.getAtNumber(), k -> new LinkedHashMap<>())
|
dailyData.computeIfAbsent(mp.getAtNumber(), k -> new LinkedHashMap<>())
|
||||||
.merge(day, d.getKwh(), Double::sum);
|
.merge(day, d.getKwh(), Double::sum);
|
||||||
}
|
}
|
||||||
@ -85,7 +85,8 @@ public class DashboardService {
|
|||||||
dailyData.getOrDefault(mp.getAtNumber(), Collections.emptyMap())
|
dailyData.getOrDefault(mp.getAtNumber(), Collections.emptyMap())
|
||||||
.getOrDefault(day, 0.0));
|
.getOrDefault(day, 0.0));
|
||||||
}
|
}
|
||||||
return new DashboardStatsDto.TimeSeriesEntry(day.atStartOfDay(), values);
|
return new DashboardStatsDto.TimeSeriesEntry(
|
||||||
|
day.atStartOfDay(systemZone).toInstant(), values);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,8 @@ spring:
|
|||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: update
|
ddl-auto: update
|
||||||
|
properties:
|
||||||
|
hibernate.jdbc.time_zone: UTC
|
||||||
show-sql: false
|
show-sql: false
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:test-only-secret-not-for-production}
|
secret: ${JWT_SECRET:test-only-secret-not-for-production}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@ -91,8 +91,8 @@ class ConsumptionDashboardServiceTest {
|
|||||||
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
|
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
|
||||||
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
|
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
|
||||||
|
|
||||||
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
|
Instant from = Instant.parse("2026-06-01T00:00:00Z");
|
||||||
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
|
Instant to = Instant.parse("2026-06-03T00:00:00Z");
|
||||||
|
|
||||||
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
|
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
|
||||||
.thenReturn(dailyRows(
|
.thenReturn(dailyRows(
|
||||||
@ -124,8 +124,8 @@ class ConsumptionDashboardServiceTest {
|
|||||||
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
|
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
|
||||||
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of());
|
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of());
|
||||||
|
|
||||||
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
|
Instant from = Instant.parse("2026-06-01T00:00:00Z");
|
||||||
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
|
Instant to = Instant.parse("2026-06-03T00:00:00Z");
|
||||||
|
|
||||||
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
|
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
|
||||||
.thenReturn(dailyRows(new Object[]{152, 2026, 10.0}));
|
.thenReturn(dailyRows(new Object[]{152, 2026, 10.0}));
|
||||||
@ -144,8 +144,8 @@ class ConsumptionDashboardServiceTest {
|
|||||||
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
|
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
|
||||||
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of());
|
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of());
|
||||||
|
|
||||||
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
|
Instant from = Instant.parse("2026-06-01T00:00:00Z");
|
||||||
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
|
Instant to = Instant.parse("2026-06-03T00:00:00Z");
|
||||||
|
|
||||||
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
|
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
|
||||||
.thenReturn(dailyRows());
|
.thenReturn(dailyRows());
|
||||||
@ -162,8 +162,8 @@ class ConsumptionDashboardServiceTest {
|
|||||||
UUID otherUserId = UUID.randomUUID();
|
UUID otherUserId = UUID.randomUUID();
|
||||||
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
|
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
|
||||||
|
|
||||||
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
|
Instant from = Instant.parse("2026-06-01T00:00:00Z");
|
||||||
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
|
Instant to = Instant.parse("2026-06-03T00:00:00Z");
|
||||||
|
|
||||||
assertThrows(AccessDeniedException.class,
|
assertThrows(AccessDeniedException.class,
|
||||||
() -> service.getDashboard(otherUserId, meteringPointId, from, to));
|
() -> service.getDashboard(otherUserId, meteringPointId, from, to));
|
||||||
@ -175,8 +175,8 @@ class ConsumptionDashboardServiceTest {
|
|||||||
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
|
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
|
||||||
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
|
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
|
||||||
|
|
||||||
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
|
Instant from = Instant.parse("2026-06-01T00:00:00Z");
|
||||||
LocalDateTime to = LocalDateTime.of(2026, 6, 1, 12, 0);
|
Instant to = Instant.parse("2026-06-01T12:00:00Z");
|
||||||
|
|
||||||
when(meteringDataRepository.aggregateConsumptionByHour(eq(meteringPointId), eq(from), eq(to)))
|
when(meteringDataRepository.aggregateConsumptionByHour(eq(meteringPointId), eq(from), eq(to)))
|
||||||
.thenReturn(hourlyRows(
|
.thenReturn(hourlyRows(
|
||||||
@ -201,8 +201,8 @@ class ConsumptionDashboardServiceTest {
|
|||||||
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
|
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
|
||||||
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
|
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
|
||||||
|
|
||||||
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
|
Instant from = Instant.parse("2026-06-01T00:00:00Z");
|
||||||
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
|
Instant to = Instant.parse("2026-06-03T00:00:00Z");
|
||||||
|
|
||||||
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
|
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
|
||||||
.thenReturn(dailyRows(new Object[]{152, 2026, 5.0}));
|
.thenReturn(dailyRows(new Object[]{152, 2026, 5.0}));
|
||||||
|
|||||||
@ -18,8 +18,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -81,8 +80,8 @@ class MeteringDataServiceTest {
|
|||||||
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
testAtNumber,
|
testAtNumber,
|
||||||
MeteringDataType.CONSUMPTION,
|
MeteringDataType.CONSUMPTION,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 0),
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 15),
|
Instant.parse("2026-01-01T00:15:00Z"),
|
||||||
1.25,
|
1.25,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@ -108,8 +107,8 @@ class MeteringDataServiceTest {
|
|||||||
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
testAtNumber,
|
testAtNumber,
|
||||||
MeteringDataType.CONSUMPTION,
|
MeteringDataType.CONSUMPTION,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 0),
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 15),
|
Instant.parse("2026-01-01T00:15:00Z"),
|
||||||
1.25,
|
1.25,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@ -149,8 +148,8 @@ class MeteringDataServiceTest {
|
|||||||
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
testAtNumber,
|
testAtNumber,
|
||||||
MeteringDataType.CONSUMPTION,
|
MeteringDataType.CONSUMPTION,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 0),
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 15),
|
Instant.parse("2026-01-01T00:15:00Z"),
|
||||||
-1.0,
|
-1.0,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@ -175,8 +174,8 @@ class MeteringDataServiceTest {
|
|||||||
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
testAtNumber,
|
testAtNumber,
|
||||||
MeteringDataType.CONSUMPTION,
|
MeteringDataType.CONSUMPTION,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 15),
|
Instant.parse("2026-01-01T00:15:00Z"),
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 0),
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
1.25,
|
1.25,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@ -214,11 +213,11 @@ class MeteringDataServiceTest {
|
|||||||
|
|
||||||
List<MeteringDataRecordDto> records = List.of(
|
List<MeteringDataRecordDto> records = List.of(
|
||||||
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
|
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,
|
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,
|
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(
|
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
|
||||||
@ -252,8 +251,8 @@ class MeteringDataServiceTest {
|
|||||||
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
testAtNumber,
|
testAtNumber,
|
||||||
MeteringDataType.CONSUMPTION,
|
MeteringDataType.CONSUMPTION,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 0),
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 15),
|
Instant.parse("2026-01-01T00:15:00Z"),
|
||||||
1.25,
|
1.25,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@ -277,7 +276,7 @@ class MeteringDataServiceTest {
|
|||||||
any(), any(), any())).thenReturn(Collections.emptyList());
|
any(), any(), any())).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
meteringDataService.getMeteringData(testUserId, testMeteringPoint.getId(),
|
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(
|
verify(meteringDataRepository).findByMeteringPointIdAndIntervalStartBetween(
|
||||||
any(), any(), any());
|
any(), any(), any());
|
||||||
@ -291,7 +290,7 @@ class MeteringDataServiceTest {
|
|||||||
|
|
||||||
assertThrows(AccessDeniedException.class, () ->
|
assertThrows(AccessDeniedException.class, () ->
|
||||||
meteringDataService.getMeteringData(otherUserId, testMeteringPoint.getId(),
|
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
|
@Test
|
||||||
@ -303,7 +302,7 @@ class MeteringDataServiceTest {
|
|||||||
assertThrows(AccessDeniedException.class, () ->
|
assertThrows(AccessDeniedException.class, () ->
|
||||||
meteringDataService.getMeteringDataByType(otherUserId, testMeteringPoint.getId(),
|
meteringDataService.getMeteringDataByType(otherUserId, testMeteringPoint.getId(),
|
||||||
MeteringDataType.CONSUMPTION,
|
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
|
@Test
|
||||||
@ -313,8 +312,8 @@ class MeteringDataServiceTest {
|
|||||||
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
testAtNumber,
|
testAtNumber,
|
||||||
MeteringDataType.FEED_IN,
|
MeteringDataType.FEED_IN,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 0),
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 15),
|
Instant.parse("2026-01-01T00:15:00Z"),
|
||||||
2.50,
|
2.50,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@ -347,8 +346,8 @@ class MeteringDataServiceTest {
|
|||||||
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
producerAtNumber,
|
producerAtNumber,
|
||||||
MeteringDataType.CONSUMPTION,
|
MeteringDataType.CONSUMPTION,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 0),
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 15),
|
Instant.parse("2026-01-01T00:15:00Z"),
|
||||||
1.25,
|
1.25,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@ -379,8 +378,8 @@ class MeteringDataServiceTest {
|
|||||||
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
testAtNumber,
|
testAtNumber,
|
||||||
MeteringDataType.SELF_CONSUMPTION,
|
MeteringDataType.SELF_CONSUMPTION,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 0),
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 15),
|
Instant.parse("2026-01-01T00:15:00Z"),
|
||||||
0.50,
|
0.50,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@ -409,11 +408,11 @@ class MeteringDataServiceTest {
|
|||||||
|
|
||||||
int recordCount = 1500;
|
int recordCount = 1500;
|
||||||
List<MeteringDataRecordDto> records = new ArrayList<>();
|
List<MeteringDataRecordDto> 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++) {
|
for (int i = 0; i < recordCount; i++) {
|
||||||
LocalDateTime intervalStart = startTime.plusMinutes(i * 15L);
|
Instant intervalStart = base.plusSeconds(i * 15L * 60);
|
||||||
LocalDateTime intervalEnd = intervalStart.plusMinutes(15);
|
Instant intervalEnd = intervalStart.plusSeconds(15L * 60);
|
||||||
records.add(new MeteringDataRecordDto(
|
records.add(new MeteringDataRecordDto(
|
||||||
testAtNumber,
|
testAtNumber,
|
||||||
MeteringDataType.CONSUMPTION,
|
MeteringDataType.CONSUMPTION,
|
||||||
@ -435,7 +434,6 @@ class MeteringDataServiceTest {
|
|||||||
assertEquals(UploadStatus.COMPLETED, response.status());
|
assertEquals(UploadStatus.COMPLETED, response.status());
|
||||||
assertEquals(recordCount, response.recordCount());
|
assertEquals(recordCount, response.recordCount());
|
||||||
assertTrue(response.validationErrors().isEmpty());
|
assertTrue(response.validationErrors().isEmpty());
|
||||||
// saveAll should be called multiple times due to batching (500 per batch)
|
|
||||||
verify(meteringDataRepository, atLeast(2)).saveAll(any());
|
verify(meteringDataRepository, atLeast(2)).saveAll(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -451,11 +449,11 @@ class MeteringDataServiceTest {
|
|||||||
|
|
||||||
List<MeteringDataRecordDto> records = List.of(
|
List<MeteringDataRecordDto> records = List.of(
|
||||||
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
|
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,
|
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,
|
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(
|
MeteringDataUploadRequest request1 = new MeteringDataUploadRequest(
|
||||||
@ -464,12 +462,10 @@ class MeteringDataServiceTest {
|
|||||||
records
|
records
|
||||||
);
|
);
|
||||||
|
|
||||||
// First upload
|
|
||||||
MeteringDataUploadResponse response1 = meteringDataService.uploadMeteringData(request1, UUID.randomUUID());
|
MeteringDataUploadResponse response1 = meteringDataService.uploadMeteringData(request1, UUID.randomUUID());
|
||||||
assertEquals(UploadStatus.COMPLETED, response1.status());
|
assertEquals(UploadStatus.COMPLETED, response1.status());
|
||||||
assertEquals(3, response1.recordCount());
|
assertEquals(3, response1.recordCount());
|
||||||
|
|
||||||
// Second upload with same data - should succeed due to deduplication
|
|
||||||
MeteringDataUploadRequest request2 = new MeteringDataUploadRequest(
|
MeteringDataUploadRequest request2 = new MeteringDataUploadRequest(
|
||||||
DataSource.EMAIL_XLSX,
|
DataSource.EMAIL_XLSX,
|
||||||
"test.xlsx",
|
"test.xlsx",
|
||||||
@ -492,15 +488,13 @@ class MeteringDataServiceTest {
|
|||||||
});
|
});
|
||||||
when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
// Create 2000 records but with only 1000 unique timestamps
|
|
||||||
List<MeteringDataRecordDto> records = new ArrayList<>();
|
List<MeteringDataRecordDto> 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++) {
|
for (int i = 0; i < 2000; i++) {
|
||||||
// Use modulo to create duplicates
|
|
||||||
int index = i % 1000;
|
int index = i % 1000;
|
||||||
LocalDateTime intervalStart = startTime.plusMinutes(index * 15L);
|
Instant intervalStart = base.plusSeconds(index * 15L * 60);
|
||||||
LocalDateTime intervalEnd = intervalStart.plusMinutes(15);
|
Instant intervalEnd = intervalStart.plusSeconds(15L * 60);
|
||||||
records.add(new MeteringDataRecordDto(
|
records.add(new MeteringDataRecordDto(
|
||||||
testAtNumber,
|
testAtNumber,
|
||||||
MeteringDataType.CONSUMPTION,
|
MeteringDataType.CONSUMPTION,
|
||||||
@ -520,8 +514,101 @@ class MeteringDataServiceTest {
|
|||||||
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID());
|
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID());
|
||||||
|
|
||||||
assertEquals(UploadStatus.COMPLETED, response.status());
|
assertEquals(UploadStatus.COMPLETED, response.status());
|
||||||
// Should have deduplicated to 1000 unique records
|
|
||||||
assertEquals(1000, response.recordCount());
|
assertEquals(1000, response.recordCount());
|
||||||
assertTrue(response.validationErrors().isEmpty());
|
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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> generateHourlyRecords(int year) {
|
||||||
|
List<MeteringDataRecordDto> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,7 +10,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
@ -24,6 +27,10 @@ class XlsxMeteringDataParserTest {
|
|||||||
parser = new XlsxMeteringDataParser();
|
parser = new XlsxMeteringDataParser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Instant toInstant(String isoDateTime) {
|
||||||
|
return LocalDateTime.parse(isoDateTime).atZone(ZoneId.systemDefault()).toInstant();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parse_validXlsxWithTwoRecords() throws IOException {
|
void parse_validXlsxWithTwoRecords() throws IOException {
|
||||||
byte[] xlsxBytes = createTestXlsx(
|
byte[] xlsxBytes = createTestXlsx(
|
||||||
@ -38,8 +45,8 @@ class XlsxMeteringDataParserTest {
|
|||||||
MeteringDataRecordDto first = records.get(0);
|
MeteringDataRecordDto first = records.get(0);
|
||||||
assertEquals("AT0010000000000000000000001234567", first.atNumber());
|
assertEquals("AT0010000000000000000000001234567", first.atNumber());
|
||||||
assertEquals(MeteringDataType.CONSUMPTION, first.dataType());
|
assertEquals(MeteringDataType.CONSUMPTION, first.dataType());
|
||||||
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0), first.intervalStart());
|
assertEquals(toInstant("2026-01-01T00:00"), first.intervalStart());
|
||||||
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 15), first.intervalEnd());
|
assertEquals(toInstant("2026-01-01T00:15"), first.intervalEnd());
|
||||||
assertEquals(1.25, first.kwh(), 0.001);
|
assertEquals(1.25, first.kwh(), 0.001);
|
||||||
|
|
||||||
MeteringDataRecordDto second = records.get(1);
|
MeteringDataRecordDto second = records.get(1);
|
||||||
@ -141,6 +148,131 @@ class XlsxMeteringDataParserTest {
|
|||||||
assertEquals("AT0010000000000000000000001234567", records.get(0).atNumber());
|
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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<String[]> 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<MeteringDataRecordDto> 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 {
|
private byte[] createTestXlsx(String[]... dataRows) throws IOException {
|
||||||
try (Workbook workbook = new XSSFWorkbook();
|
try (Workbook workbook = new XSSFWorkbook();
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||||
|
|||||||
12
eeg_backend/src/test/resources/application-test.yml
Normal file
12
eeg_backend/src/test/resources/application-test.yml
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user