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);
}
@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}")
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<List<MeteringDataResponse>> getMeteringData(

View File

@ -23,6 +23,13 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStart(
UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart);
@Query("SELECT m FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId " +
"AND m.dataType = :dataType AND m.intervalStart IN :intervalStarts")
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartIn(
@Param("meteringPointId") UUID meteringPointId,
@Param("dataType") MeteringDataType dataType,
@Param("intervalStarts") List<LocalDateTime> intervalStarts);
@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 " +

View File

@ -29,7 +29,8 @@ public class MeteringDataService {
@Transactional
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()) {
return MeteringDataUploadResponse.validationFailed(validationErrors);
@ -43,8 +44,6 @@ public class MeteringDataService {
upload.setUploadedBy(uploadedBy);
MeteringDataUpload savedUpload = uploadRepository.save(upload);
Map<String, MeteringPoint> pointsByAtNumber = resolveMeteringPoints(request.records());
List<MeteringData> 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<String> validateRecords(List<MeteringDataRecordDto> records) {
private List<String> validateRecords(List<MeteringDataRecordDto> records, Map<String, MeteringPoint> pointsByAtNumber) {
List<String> errors = new ArrayList<>();
if (records == null || records.isEmpty()) {
@ -100,22 +99,17 @@ public class MeteringDataService {
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++) {
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<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<>();
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<LocalDateTime> starts = typeEntry.getValue();
toDelete.addAll(meteringDataRepository.findByMeteringPointIdAndDataTypeAndIntervalStartIn(
pointId, dataType, starts));
}
}
if (!toDelete.isEmpty()) {

View File

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

View File

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

View File

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

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