From a651ec946c72f5036d7569db995e0b5a314995d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernhard=20M=C3=BCller?= Date: Thu, 23 Jul 2026 07:07:52 +0200 Subject: [PATCH] feat(community): add metering data upload UI with preview and validation - Add frontend upload page with file selection, drag-and-drop, and preview - Add preview endpoint (POST /api/community/metering-data/preview/xlsx) - Validate FEED_IN only for PRODUCER, CONSUMPTION only for CONSUMER metering points - Optimize deduplication with bulk query (fix N+1 problem) - Eliminate duplicate DB query in validateRecords - Add 4 new tests for data type validation - Add navigation entry for admin users --- .../community/api/MeteringDataController.java | 8 ++ .../repository/MeteringDataRepository.java | 7 ++ .../service/MeteringDataService.java | 51 ++++---- .../service/MeteringDataServiceTest.java | 103 +++++++++++++++- eeg_frontend/src/app/app.routes.ts | 6 + .../dashboard-layout/dashboard-nav-items.ts | 1 + .../upload-metering-data.html | 113 ++++++++++++++++++ .../upload-metering-data.ts | 113 ++++++++++++++++++ 8 files changed, 380 insertions(+), 22 deletions(-) create mode 100644 eeg_frontend/src/app/pages/upload-metering-data/upload-metering-data.html create mode 100644 eeg_frontend/src/app/pages/upload-metering-data/upload-metering-data.ts diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java index 4c71986..440d39f 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java @@ -61,6 +61,14 @@ public class MeteringDataController { return ResponseEntity.status(HttpStatus.CREATED).body(response); } + @PostMapping(value = "/preview/xlsx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> previewXlsx( + @RequestParam("file") MultipartFile file) throws IOException { + List records = xlsxParser.parse(file.getInputStream()); + return ResponseEntity.ok(records.stream().limit(10).toList()); + } + @GetMapping("/{meteringPointId}") @PreAuthorize("hasRole('MEMBER')") public ResponseEntity> getMeteringData( diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java index 0720cd3..8f5c229 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java @@ -23,6 +23,13 @@ public interface MeteringDataRepository extends JpaRepository findByMeteringPointIdAndDataTypeAndIntervalStart( UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart); + @Query("SELECT m FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId " + + "AND m.dataType = :dataType AND m.intervalStart IN :intervalStarts") + List findByMeteringPointIdAndDataTypeAndIntervalStartIn( + @Param("meteringPointId") UUID meteringPointId, + @Param("dataType") MeteringDataType dataType, + @Param("intervalStarts") List intervalStarts); + @Query("SELECT FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " + "FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId AND m.dataType = 'CONSUMPTION' " + "AND m.intervalStart BETWEEN :from AND :to " + diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java index 4e2b41b..61cbf99 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java @@ -29,7 +29,8 @@ public class MeteringDataService { @Transactional public MeteringDataUploadResponse uploadMeteringData(MeteringDataUploadRequest request, UUID uploadedBy) { - List validationErrors = validateRecords(request.records()); + Map pointsByAtNumber = resolveMeteringPoints(request.records()); + List validationErrors = validateRecords(request.records(), pointsByAtNumber); if (!validationErrors.isEmpty()) { return MeteringDataUploadResponse.validationFailed(validationErrors); @@ -43,8 +44,6 @@ public class MeteringDataService { upload.setUploadedBy(uploadedBy); MeteringDataUpload savedUpload = uploadRepository.save(upload); - Map pointsByAtNumber = resolveMeteringPoints(request.records()); - List entities = request.records().stream() .map(record -> toEntity(record, pointsByAtNumber.get(record.atNumber()), savedUpload.getId(), request.source())) @@ -92,7 +91,7 @@ public class MeteringDataService { "Keine Berechtigung zum Zugriff auf diesen Zählpunkt")); } - private List validateRecords(List records) { + private List validateRecords(List records, Map pointsByAtNumber) { List errors = new ArrayList<>(); if (records == null || records.isEmpty()) { @@ -100,22 +99,17 @@ public class MeteringDataService { return errors; } - Set atNumbers = records.stream() - .map(MeteringDataRecordDto::atNumber) - .collect(Collectors.toSet()); - - Set existingAtNumbers = meteringPointRepository.findByAtNumberIn(atNumbers).stream() - .map(MeteringPoint::getAtNumber) - .collect(Collectors.toSet()); - for (int i = 0; i < records.size(); i++) { MeteringDataRecordDto record = records.get(i); String prefix = "Zeile " + (i + 1) + ": "; - if (!existingAtNumbers.contains(record.atNumber())) { + if (!pointsByAtNumber.containsKey(record.atNumber())) { errors.add(prefix + "Zählpunkt " + record.atNumber() + " nicht im System vorhanden"); + continue; } + MeteringPoint point = pointsByAtNumber.get(record.atNumber()); + if (record.intervalEnd().isBefore(record.intervalStart())) { errors.add(prefix + "Intervall-Ende liegt vor Intervall-Beginn"); } @@ -123,6 +117,13 @@ public class MeteringDataService { if (record.kwh() < 0) { errors.add(prefix + "kWh-Wert darf nicht negativ sein"); } + + if (record.dataType() == MeteringDataType.FEED_IN && point.getType() != PointType.PRODUCER) { + errors.add(prefix + "Einspeisedaten (FEED_IN) nur für PRODUCER-Zählpunkte erlaubt"); + } + if (record.dataType() == MeteringDataType.CONSUMPTION && point.getType() != PointType.CONSUMER) { + errors.add(prefix + "Verbrauchsdaten (CONSUMPTION) nur für CONSUMER-Zählpunkte erlaubt"); + } } return errors; @@ -156,15 +157,25 @@ public class MeteringDataService { return; } + Map>> grouped = entities.stream() + .collect(Collectors.groupingBy( + e -> e.getMeteringPoint().getId(), + Collectors.groupingBy( + MeteringData::getDataType, + Collectors.mapping(MeteringData::getIntervalStart, Collectors.toList()) + ) + )); + Set toDelete = new LinkedHashSet<>(); - for (MeteringData entity : entities) { - meteringDataRepository - .findByMeteringPointIdAndDataTypeAndIntervalStart( - entity.getMeteringPoint().getId(), - entity.getDataType(), - entity.getIntervalStart()) - .forEach(toDelete::add); + for (var pointEntry : grouped.entrySet()) { + UUID pointId = pointEntry.getKey(); + for (var typeEntry : pointEntry.getValue().entrySet()) { + MeteringDataType dataType = typeEntry.getKey(); + List starts = typeEntry.getValue(); + toDelete.addAll(meteringDataRepository.findByMeteringPointIdAndDataTypeAndIntervalStartIn( + pointId, dataType, starts)); + } } if (!toDelete.isEmpty()) { diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java index 3088872..a620bdf 100644 --- a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java @@ -190,7 +190,15 @@ class MeteringDataServiceTest { @Test void uploadMeteringData_multipleRecords() { - when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint)); + String producerAtNumber = "AT0010000000000000000000001234568"; + MeteringPoint producerPoint = new MeteringPoint(); + producerPoint.setId(UUID.randomUUID()); + producerPoint.setUserId(testUserId); + producerPoint.setAtNumber(producerAtNumber); + producerPoint.setType(PointType.PRODUCER); + producerPoint.setMakoState(MakoState.ACTIVE); + + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint, producerPoint)); when(uploadRepository.save(any())).thenAnswer(invocation -> { MeteringDataUpload upload = invocation.getArgument(0); upload.setId(UUID.randomUUID()); @@ -203,7 +211,7 @@ class MeteringDataServiceTest { LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 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), - new MeteringDataRecordDto(testAtNumber, 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) ); @@ -291,4 +299,95 @@ class MeteringDataServiceTest { MeteringDataType.CONSUMPTION, LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59))); } + + @Test + void uploadMeteringData_validationFailsForFeedInOnConsumerPoint() { + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint)); + + MeteringDataRecordDto record = new MeteringDataRecordDto( + testAtNumber, + MeteringDataType.FEED_IN, + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 15), + 2.50, + null + ); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + "test.xlsx", + List.of(record) + ); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertEquals(UploadStatus.FAILED, response.status()); + assertFalse(response.validationErrors().isEmpty()); + assertTrue(response.validationErrors().get(0).contains("PRODUCER")); + } + + @Test + void uploadMeteringData_validationFailsForConsumptionOnProducerPoint() { + String producerAtNumber = "AT0010000000000000000000001234568"; + MeteringPoint producerPoint = new MeteringPoint(); + producerPoint.setId(UUID.randomUUID()); + producerPoint.setUserId(testUserId); + producerPoint.setAtNumber(producerAtNumber); + producerPoint.setType(PointType.PRODUCER); + producerPoint.setMakoState(MakoState.ACTIVE); + + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(producerPoint)); + + MeteringDataRecordDto record = new MeteringDataRecordDto( + producerAtNumber, + MeteringDataType.CONSUMPTION, + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 15), + 1.25, + null + ); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + "test.xlsx", + List.of(record) + ); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertEquals(UploadStatus.FAILED, response.status()); + assertFalse(response.validationErrors().isEmpty()); + assertTrue(response.validationErrors().get(0).contains("CONSUMER")); + } + + @Test + void uploadMeteringData_allowsSelfConsumptionOnBothTypes() { + 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)); + + MeteringDataRecordDto record = new MeteringDataRecordDto( + testAtNumber, + MeteringDataType.SELF_CONSUMPTION, + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 15), + 0.50, + null + ); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + "test.xlsx", + List.of(record) + ); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertEquals(UploadStatus.COMPLETED, response.status()); + assertEquals(1, response.recordCount()); + } } diff --git a/eeg_frontend/src/app/app.routes.ts b/eeg_frontend/src/app/app.routes.ts index 982bd4e..6a966ec 100644 --- a/eeg_frontend/src/app/app.routes.ts +++ b/eeg_frontend/src/app/app.routes.ts @@ -19,6 +19,7 @@ import {ResetPasswordComponent} from './pages/reset-password/reset-password'; import {AdminTariffComponent} from './pages/admin-tariff/admin-tariff'; import {UserTariffComponent} from './pages/user-tariff/user-tariff'; import {ConsumptionDashboardComponent} from './pages/consumption-dashboard/consumption-dashboard'; +import {UploadMeteringDataComponent} from './pages/upload-metering-data/upload-metering-data'; export const routes: Routes = [ { path: '', component: LandingPage }, @@ -68,6 +69,11 @@ export const routes: Routes = [ component: AdminTariffComponent, canActivate: [roleGuard(['ADMIN'])] }, + { + path: 'upload-metering-data', + component: UploadMeteringDataComponent, + canActivate: [roleGuard(['ADMIN'])] + }, { path: 'my-tariffs', component: UserTariffComponent, diff --git a/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts b/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts index bd6638c..c31a108 100644 --- a/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts +++ b/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts @@ -15,5 +15,6 @@ export const NAV_ITEMS: NavItem[] = [ {label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']}, {label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']}, {label: 'Tarife verwalten', path: '/dashboard/tariffs', roles: ['ADMIN']}, + {label: 'Messdaten-Upload', path: '/dashboard/upload-metering-data', roles: ['ADMIN']}, {label: 'Meine Tarife', path: '/dashboard/my-tariffs', roles: ['MEMBER']} ]; diff --git a/eeg_frontend/src/app/pages/upload-metering-data/upload-metering-data.html b/eeg_frontend/src/app/pages/upload-metering-data/upload-metering-data.html new file mode 100644 index 0000000..181c063 --- /dev/null +++ b/eeg_frontend/src/app/pages/upload-metering-data/upload-metering-data.html @@ -0,0 +1,113 @@ +
+

Messdaten-Upload

+ +
+

+ Laden Sie eine Excel-Datei (XLSX) mit Messdaten hoch. +

+ +
+ + +

+ oder Datei hierher ziehen +

+
+ + @if (selectedFile()) { +
+ {{ selectedFile()!.name }} + +
+ } + + @if (isLoadingPreview()) { +
+ Vorschau wird geladen... +
+ } + + @if (previewData().length > 0) { +
+

Vorschau (erste 10 Zeilen):

+
+ + + + + + + + + + + + @for (row of previewData(); track $index) { + + + + + + + + } + +
AT-NummerTypVonBiskWh
{{ row.atNumber }}{{ row.dataType }}{{ row.intervalStart }}{{ row.intervalEnd }}{{ row.kwh }}
+
+
+ } + + @if (uploadResult()) { +
+

+ @if (uploadResult()!.status === 'COMPLETED') { + Upload erfolgreich! + } @else { + Upload fehlgeschlagen + } +

+

+ {{ uploadResult()!.recordCount }} Datensätze verarbeitet +

+ @if (uploadResult()!.validationErrors.length > 0) { +
    + @for (error of uploadResult()!.validationErrors; track error) { +
  • {{ error }}
  • + } +
+ } +
+ } + + @if (errorMessage()) { +
+

{{ errorMessage() }}

+
+ } +
+
diff --git a/eeg_frontend/src/app/pages/upload-metering-data/upload-metering-data.ts b/eeg_frontend/src/app/pages/upload-metering-data/upload-metering-data.ts new file mode 100644 index 0000000..5a0f603 --- /dev/null +++ b/eeg_frontend/src/app/pages/upload-metering-data/upload-metering-data.ts @@ -0,0 +1,113 @@ +import {Component, inject, signal} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {environment} from '../../../environments/environment'; + +interface PreviewRecord { + atNumber: string; + dataType: string; + intervalStart: string; + intervalEnd: string; + kwh: number; + readingValue: number | null; +} + +interface UploadResponse { + uploadId: string; + status: string; + recordCount: number; + validationErrors: string[]; +} + +@Component({ + selector: 'app-upload-metering-data', + standalone: true, + templateUrl: './upload-metering-data.html', +}) +export class UploadMeteringDataComponent { + private http = inject(HttpClient); + + selectedFile = signal(null); + previewData = signal([]); + isLoadingPreview = signal(false); + isUploading = signal(false); + uploadResult = signal(null); + errorMessage = signal(null); + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.selectedFile.set(input.files[0]); + this.uploadResult.set(null); + this.errorMessage.set(null); + this.loadPreview(input.files[0]); + } + } + + onDragOver(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + } + + onDrop(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + const files = event.dataTransfer?.files; + if (files && files.length > 0) { + this.selectedFile.set(files[0]); + this.uploadResult.set(null); + this.errorMessage.set(null); + this.loadPreview(files[0]); + } + } + + private loadPreview(file: File) { + this.isLoadingPreview.set(true); + this.previewData.set([]); + this.errorMessage.set(null); + + const formData = new FormData(); + formData.append('file', file); + + this.http.post( + `${environment.apiUrl}/api/community/metering-data/preview/xlsx`, + formData + ).subscribe({ + next: (data) => { + this.previewData.set(data); + this.isLoadingPreview.set(false); + }, + error: (error) => { + this.errorMessage.set(error.error?.message || 'Fehler beim Laden der Vorschau'); + this.isLoadingPreview.set(false); + } + }); + } + + upload() { + const file = this.selectedFile(); + if (!file) return; + + this.isUploading.set(true); + this.uploadResult.set(null); + this.errorMessage.set(null); + + const formData = new FormData(); + formData.append('file', file); + + this.http.post( + `${environment.apiUrl}/api/community/metering-data/upload/xlsx`, + formData + ).subscribe({ + next: (response) => { + this.uploadResult.set(response); + this.isUploading.set(false); + this.selectedFile.set(null); + this.previewData.set([]); + }, + error: (error) => { + this.errorMessage.set(error.error?.message || 'Fehler beim Upload'); + this.isUploading.set(false); + } + }); + } +}