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
This commit is contained in:
Bernhard Müller 2026-07-23 07:07:52 +02:00
parent 4b7b26231f
commit a651ec946c
8 changed files with 380 additions and 22 deletions

View File

@ -61,6 +61,14 @@ public class MeteringDataController {
return ResponseEntity.status(HttpStatus.CREATED).body(response); return ResponseEntity.status(HttpStatus.CREATED).body(response);
} }
@PostMapping(value = "/preview/xlsx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<MeteringDataRecordDto>> previewXlsx(
@RequestParam("file") MultipartFile file) throws IOException {
List<MeteringDataRecordDto> records = xlsxParser.parse(file.getInputStream());
return ResponseEntity.ok(records.stream().limit(10).toList());
}
@GetMapping("/{meteringPointId}") @GetMapping("/{meteringPointId}")
@PreAuthorize("hasRole('MEMBER')") @PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<List<MeteringDataResponse>> getMeteringData( public ResponseEntity<List<MeteringDataResponse>> getMeteringData(

View File

@ -23,6 +23,13 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStart( List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStart(
UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart); 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);
@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 = :meteringPointId AND m.dataType = 'CONSUMPTION' " + "FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId AND m.dataType = 'CONSUMPTION' " +
"AND m.intervalStart BETWEEN :from AND :to " + "AND m.intervalStart BETWEEN :from AND :to " +

View File

@ -29,7 +29,8 @@ public class MeteringDataService {
@Transactional @Transactional
public MeteringDataUploadResponse uploadMeteringData(MeteringDataUploadRequest request, UUID uploadedBy) { public MeteringDataUploadResponse uploadMeteringData(MeteringDataUploadRequest request, UUID uploadedBy) {
List<String> validationErrors = validateRecords(request.records()); Map<String, MeteringPoint> pointsByAtNumber = resolveMeteringPoints(request.records());
List<String> validationErrors = validateRecords(request.records(), pointsByAtNumber);
if (!validationErrors.isEmpty()) { if (!validationErrors.isEmpty()) {
return MeteringDataUploadResponse.validationFailed(validationErrors); return MeteringDataUploadResponse.validationFailed(validationErrors);
@ -43,8 +44,6 @@ public class MeteringDataService {
upload.setUploadedBy(uploadedBy); upload.setUploadedBy(uploadedBy);
MeteringDataUpload savedUpload = uploadRepository.save(upload); MeteringDataUpload savedUpload = uploadRepository.save(upload);
Map<String, MeteringPoint> pointsByAtNumber = resolveMeteringPoints(request.records());
List<MeteringData> entities = request.records().stream() List<MeteringData> entities = request.records().stream()
.map(record -> toEntity(record, pointsByAtNumber.get(record.atNumber()), .map(record -> toEntity(record, pointsByAtNumber.get(record.atNumber()),
savedUpload.getId(), request.source())) savedUpload.getId(), request.source()))
@ -92,7 +91,7 @@ public class MeteringDataService {
"Keine Berechtigung zum Zugriff auf diesen Zählpunkt")); "Keine Berechtigung zum Zugriff auf diesen Zählpunkt"));
} }
private List<String> validateRecords(List<MeteringDataRecordDto> records) { private List<String> validateRecords(List<MeteringDataRecordDto> records, Map<String, MeteringPoint> pointsByAtNumber) {
List<String> errors = new ArrayList<>(); List<String> errors = new ArrayList<>();
if (records == null || records.isEmpty()) { if (records == null || records.isEmpty()) {
@ -100,22 +99,17 @@ public class MeteringDataService {
return errors; return errors;
} }
Set<String> atNumbers = records.stream()
.map(MeteringDataRecordDto::atNumber)
.collect(Collectors.toSet());
Set<String> existingAtNumbers = meteringPointRepository.findByAtNumberIn(atNumbers).stream()
.map(MeteringPoint::getAtNumber)
.collect(Collectors.toSet());
for (int i = 0; i < records.size(); i++) { for (int i = 0; i < records.size(); i++) {
MeteringDataRecordDto record = records.get(i); MeteringDataRecordDto record = records.get(i);
String prefix = "Zeile " + (i + 1) + ": "; 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"); errors.add(prefix + "Zählpunkt " + record.atNumber() + " nicht im System vorhanden");
continue;
} }
MeteringPoint point = pointsByAtNumber.get(record.atNumber());
if (record.intervalEnd().isBefore(record.intervalStart())) { if (record.intervalEnd().isBefore(record.intervalStart())) {
errors.add(prefix + "Intervall-Ende liegt vor Intervall-Beginn"); errors.add(prefix + "Intervall-Ende liegt vor Intervall-Beginn");
} }
@ -123,6 +117,13 @@ public class MeteringDataService {
if (record.kwh() < 0) { if (record.kwh() < 0) {
errors.add(prefix + "kWh-Wert darf nicht negativ sein"); 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; return errors;
@ -156,15 +157,25 @@ public class MeteringDataService {
return; return;
} }
Map<UUID, Map<MeteringDataType, List<LocalDateTime>>> grouped = entities.stream()
.collect(Collectors.groupingBy(
e -> e.getMeteringPoint().getId(),
Collectors.groupingBy(
MeteringData::getDataType,
Collectors.mapping(MeteringData::getIntervalStart, Collectors.toList())
)
));
Set<MeteringData> toDelete = new LinkedHashSet<>(); Set<MeteringData> toDelete = new LinkedHashSet<>();
for (MeteringData entity : entities) { for (var pointEntry : grouped.entrySet()) {
meteringDataRepository UUID pointId = pointEntry.getKey();
.findByMeteringPointIdAndDataTypeAndIntervalStart( for (var typeEntry : pointEntry.getValue().entrySet()) {
entity.getMeteringPoint().getId(), MeteringDataType dataType = typeEntry.getKey();
entity.getDataType(), List<LocalDateTime> starts = typeEntry.getValue();
entity.getIntervalStart()) toDelete.addAll(meteringDataRepository.findByMeteringPointIdAndDataTypeAndIntervalStartIn(
.forEach(toDelete::add); pointId, dataType, starts));
}
} }
if (!toDelete.isEmpty()) { if (!toDelete.isEmpty()) {

View File

@ -190,7 +190,15 @@ class MeteringDataServiceTest {
@Test @Test
void uploadMeteringData_multipleRecords() { 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 -> { when(uploadRepository.save(any())).thenAnswer(invocation -> {
MeteringDataUpload upload = invocation.getArgument(0); MeteringDataUpload upload = invocation.getArgument(0);
upload.setId(UUID.randomUUID()); 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), LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 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), 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) 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, MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59))); 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());
}
} }

View File

@ -19,6 +19,7 @@ import {ResetPasswordComponent} from './pages/reset-password/reset-password';
import {AdminTariffComponent} from './pages/admin-tariff/admin-tariff'; import {AdminTariffComponent} from './pages/admin-tariff/admin-tariff';
import {UserTariffComponent} from './pages/user-tariff/user-tariff'; import {UserTariffComponent} from './pages/user-tariff/user-tariff';
import {ConsumptionDashboardComponent} from './pages/consumption-dashboard/consumption-dashboard'; import {ConsumptionDashboardComponent} from './pages/consumption-dashboard/consumption-dashboard';
import {UploadMeteringDataComponent} from './pages/upload-metering-data/upload-metering-data';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: LandingPage }, { path: '', component: LandingPage },
@ -68,6 +69,11 @@ export const routes: Routes = [
component: AdminTariffComponent, component: AdminTariffComponent,
canActivate: [roleGuard(['ADMIN'])] canActivate: [roleGuard(['ADMIN'])]
}, },
{
path: 'upload-metering-data',
component: UploadMeteringDataComponent,
canActivate: [roleGuard(['ADMIN'])]
},
{ {
path: 'my-tariffs', path: 'my-tariffs',
component: UserTariffComponent, component: UserTariffComponent,

View File

@ -15,5 +15,6 @@ export const NAV_ITEMS: NavItem[] = [
{label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']}, {label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']},
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']}, {label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']},
{label: 'Tarife verwalten', path: '/dashboard/tariffs', 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']} {label: 'Meine Tarife', path: '/dashboard/my-tariffs', roles: ['MEMBER']}
]; ];

View File

@ -0,0 +1,113 @@
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Messdaten-Upload</h1>
<div class="bg-white rounded-lg shadow p-6">
<p class="text-gray-600 mb-4">
Laden Sie eine Excel-Datei (XLSX) mit Messdaten hoch.
</p>
<div
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors"
(dragover)="onDragOver($event)"
(drop)="onDrop($event)"
>
<input
type="file"
accept=".xlsx,.xls"
(change)="onFileSelected($event)"
class="hidden"
#fileInput
/>
<button
(click)="fileInput.click()"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Datei auswählen
</button>
<p class="mt-2 text-sm text-gray-500">
oder Datei hierher ziehen
</p>
</div>
@if (selectedFile()) {
<div class="mt-4 p-3 bg-gray-50 rounded flex items-center justify-between">
<span class="text-sm text-gray-700">{{ selectedFile()!.name }}</span>
<button
(click)="upload()"
[disabled]="isUploading()"
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
>
@if (isUploading()) {
Wird hochgeladen...
} @else {
Upload starten
}
</button>
</div>
}
@if (isLoadingPreview()) {
<div class="mt-4 text-center text-gray-500">
Vorschau wird geladen...
</div>
}
@if (previewData().length > 0) {
<div class="mt-4">
<h3 class="text-sm font-medium text-gray-700 mb-2">Vorschau (erste 10 Zeilen):</h3>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-100">
<tr>
<th class="px-3 py-2 text-left">AT-Nummer</th>
<th class="px-3 py-2 text-left">Typ</th>
<th class="px-3 py-2 text-left">Von</th>
<th class="px-3 py-2 text-left">Bis</th>
<th class="px-3 py-2 text-right">kWh</th>
</tr>
</thead>
<tbody>
@for (row of previewData(); track $index) {
<tr class="border-t">
<td class="px-3 py-2">{{ row.atNumber }}</td>
<td class="px-3 py-2">{{ row.dataType }}</td>
<td class="px-3 py-2">{{ row.intervalStart }}</td>
<td class="px-3 py-2">{{ row.intervalEnd }}</td>
<td class="px-3 py-2 text-right">{{ row.kwh }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@if (uploadResult()) {
<div class="mt-4 p-4 rounded" [class]="uploadResult()!.status === 'COMPLETED' ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'">
<p class="font-semibold" [class]="uploadResult()!.status === 'COMPLETED' ? 'text-green-800' : 'text-red-800'">
@if (uploadResult()!.status === 'COMPLETED') {
Upload erfolgreich!
} @else {
Upload fehlgeschlagen
}
</p>
<p class="text-sm text-gray-600 mt-1">
{{ uploadResult()!.recordCount }} Datensätze verarbeitet
</p>
@if (uploadResult()!.validationErrors.length > 0) {
<ul class="mt-2 text-sm text-red-600 list-disc list-inside">
@for (error of uploadResult()!.validationErrors; track error) {
<li>{{ error }}</li>
}
</ul>
}
</div>
}
@if (errorMessage()) {
<div class="mt-4 p-4 bg-red-50 border border-red-200 rounded">
<p class="text-red-800">{{ errorMessage() }}</p>
</div>
}
</div>
</div>

View File

@ -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<File | null>(null);
previewData = signal<PreviewRecord[]>([]);
isLoadingPreview = signal(false);
isUploading = signal(false);
uploadResult = signal<UploadResponse | null>(null);
errorMessage = signal<string | null>(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<PreviewRecord[]>(
`${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<UploadResponse>(
`${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);
}
});
}
}