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:
Bernhard Müller 2026-07-24 13:23:35 +02:00
parent cfc32b3a7d
commit fb53b169a2
19 changed files with 525 additions and 129 deletions

View File

@ -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));
} }

View File

@ -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));
} }

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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;

View File

@ -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);
} }

View File

@ -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;

View File

@ -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() + "|" +

View File

@ -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;
} }

View File

@ -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));
} }

View File

@ -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
) {} ) {}
} }

View File

@ -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();

View File

@ -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}

View File

@ -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}));

View File

@ -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());
}
} }

View File

@ -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;
}
}

View File

@ -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()) {

View 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