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.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<ConsumptionDashboardResponse> 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));
|
||||
}
|
||||
|
||||
@ -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<List<MeteringDataResponse>> 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));
|
||||
}
|
||||
|
||||
@ -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<IntervalData> data,
|
||||
Summary summary
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<MeteringData, UUID> {
|
||||
|
||||
List<MeteringData> findByMeteringPointIdAndIntervalStartBetween(
|
||||
UUID meteringPointId, LocalDateTime from, LocalDateTime to);
|
||||
UUID meteringPointId, Instant from, Instant to);
|
||||
|
||||
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> 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<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartIn(
|
||||
@Param("meteringPointId") UUID meteringPointId,
|
||||
@Param("dataType") MeteringDataType dataType,
|
||||
@Param("intervalStarts") List<LocalDateTime> intervalStarts);
|
||||
@Param("intervalStarts") List<Instant> intervalStarts);
|
||||
|
||||
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) " +
|
||||
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)")
|
||||
List<Object[]> 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<MeteringData, UUID
|
||||
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart) " +
|
||||
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart)")
|
||||
List<Object[]> 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<MeteringData, UUID
|
||||
"GROUP 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,
|
||||
@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<MeteringData, UUID
|
||||
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart) " +
|
||||
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart)")
|
||||
List<Object[]> aggregateFeedInByHour(@Param("meteringPointIds") List<UUID> meteringPointIds,
|
||||
@Param("from") LocalDateTime from,
|
||||
@Param("to") LocalDateTime to);
|
||||
@Param("from") Instant from,
|
||||
@Param("to") Instant to);
|
||||
}
|
||||
|
||||
@ -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> membership = membershipRepository.findByMeteringPointId(meteringPointId)
|
||||
@ -69,18 +70,19 @@ public class ConsumptionDashboardService {
|
||||
}
|
||||
}
|
||||
|
||||
Map<LocalDateTime, Double> consumptionMap;
|
||||
Map<LocalDateTime, Double> feedInMap;
|
||||
ZoneId systemZone = ZoneId.systemDefault();
|
||||
Map<Instant, Double> consumptionMap;
|
||||
Map<Instant, Double> 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<LocalDateTime> allTimestamps = new TreeSet<>(consumptionMap.keySet());
|
||||
Set<Instant> allTimestamps = new TreeSet<>(consumptionMap.keySet());
|
||||
allTimestamps.addAll(feedInMap.keySet());
|
||||
|
||||
List<IntervalData> data = allTimestamps.stream()
|
||||
@ -112,28 +114,28 @@ public class ConsumptionDashboardService {
|
||||
"Keine Berechtigung zum Zugriff auf diesen Zählpunkt"));
|
||||
}
|
||||
|
||||
private Map<LocalDateTime, Double> parseDailyMap(List<Object[]> rows) {
|
||||
Map<LocalDateTime, Double> result = new TreeMap<>();
|
||||
private Map<Instant, Double> parseDailyMap(List<Object[]> rows, ZoneId zone) {
|
||||
Map<Instant, Double> 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<LocalDateTime, Double> parseHourlyMap(List<Object[]> rows) {
|
||||
Map<LocalDateTime, Double> result = new TreeMap<>();
|
||||
private Map<Instant, Double> parseHourlyMap(List<Object[]> rows, ZoneId zone) {
|
||||
Map<Instant, Double> 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;
|
||||
|
||||
@ -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<MeteringDataResponse> 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<MeteringDataResponse> 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<String, MeteringData> uniqueByKey = new LinkedHashMap<>();
|
||||
for (MeteringData entity : entities) {
|
||||
String key = entity.getMeteringPoint().getId() + "|" +
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<DashboardStatsDto.MeteringDataOverview> 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));
|
||||
}
|
||||
|
||||
@ -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<String, Double> values
|
||||
) {}
|
||||
}
|
||||
@ -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<MeteringPoint> meteringPoints = meteringPointRepository.findAllByUserId(userId);
|
||||
|
||||
List<DashboardStatsDto.MeteringPointSummary> summaries = new ArrayList<>();
|
||||
Map<String, Map<LocalDate, Double>> dailyData = new LinkedHashMap<>();
|
||||
ZoneId systemZone = ZoneId.systemDefault();
|
||||
|
||||
for (MeteringPoint mp : meteringPoints) {
|
||||
List<MeteringData> 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();
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}));
|
||||
|
||||
@ -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<MeteringDataRecordDto> 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<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++) {
|
||||
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<MeteringDataRecordDto> 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<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++) {
|
||||
// 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<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.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<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 {
|
||||
try (Workbook workbook = new XSSFWorkbook();
|
||||
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