feat(community): add metering data upload and query with XLSX support

Implement complete metering data management for energy communities:

- MeteringData entity with unique constraint (metering_point_id, interval_start, data_type)
- Bulk upload via JSON or XLSX (multipart) with validation
- XLSX parser using Apache POI with comma-decimal support
- Ownership check on GET endpoints (userId must match metering point owner)
- Deduplication: existing records overwritten on re-upload
- DataSource tracking (EMAIL_XLSX, API, MANUAL)
- Query endpoints: by metering point, by type, by upload batch

Additional fixes:
- MapStruct annotation processor: add to execution-level annotationProcessorPaths
  (Spring Boot parent POM was overriding plugin-level config)
- EnergyCommunityMapper + MeteringDataMapper: componentModel=spring
- UserMapper: ignore passwordResetToken/Expiry (unmapped target policy ERROR)
- TariffService: add TariffInviteRepository dependency + invitation check
- TariffServiceTest: add missing @Mock for TariffInviteRepository
- SecurityConfig: permit /actuator/health and /actuator/info
- pom.xml: add poi-ooxml, postgresql, spring-boot-starter-actuator
- .gitignore: add *.log, survey/, backend.log

Tests: 137/137 passing
This commit is contained in:
Bernhard Müller 2026-07-22 12:42:27 +02:00
parent 949e1e6c34
commit 975f76dbd3
45 changed files with 2573 additions and 4 deletions

31
.dockerignore Normal file
View file

@ -0,0 +1,31 @@
**/node_modules
**/dist
**/.angular
**/.git
**/.idea
**/*.iws
**/*.iml
**/*.ipr
**/target
**/*.db
**/*.lock.db
**/.mimocode
**/HELP.md
**/.apt_generated
**/.classpath
**/.factorypath
**/.project
**/.settings
**/.springBeans
**/.sts4-cache
**/.vscode
**/nbproject
**/nbbuild
**/nbdist
**/.nb-gradle
**/build
**/test_*.json
**/server_output.log
**/run_test.ps1
**/projekt_kontext.txt
**/.mvn/wrapper/maven-wrapper.jar

33
.env.example Normal file
View file

@ -0,0 +1,33 @@
# ============================================================
# EEG Portal - Environment Configuration
# Copy this file to .env and fill in the values
# ============================================================
# Required: JWT secret for token signing (use a strong random string)
JWT_SECRET=your-super-secret-jwt-key-change-this
# Required: Frontend URL (your domain)
APP_FRONTEND_URL=https://your-domain.at
# Required: Backend API URL
APP_BACKEND_URL=https://your-domain.at
# Required: CORS allowed origins (comma-separated)
CORS_ORIGINS=https://your-domain.at
# Required: PostgreSQL configuration
POSTGRES_DB=eegportal
POSTGRES_USER=eeg
POSTGRES_PASSWORD=your-strong-db-password-change-this
# Optional: Server port (default: 80)
APP_PORT=80
# Optional: Mail configuration
# MAIL_HOST=smtp.your-provider.at
# MAIL_PORT=587
# MAIL_USERNAME=your-email@domain.at
# MAIL_PASSWORD=your-mail-password
# Optional: JVM settings
# JAVA_OPTS=-Xms256m -Xmx512m

9
.gitignore vendored
View file

@ -49,3 +49,12 @@ test_*.json
.mimocode/
run_test.ps1
server_output.log
backend.log
*.log
### Research / Umfeld ###
survey/
### Docker ###
.env
docker-compose.override.yml

View file

@ -4,7 +4,7 @@
**EEG Portal** - Portal zur Verwaltung von Energiegemeinschaften in Österreich.
Behandelt: Benutzerregistrierung, Messpunkt-Consent-Workflows (MaKo), EDA-Topologieprüfungen,
Energiegemeinschaftsverwaltung, Mitgliedschaften und Tarife.
Energiegemeinschaftsverwaltung, Mitgliedschaften, Tarife und Messdaten-Verwaltung.
## Architektur
@ -12,12 +12,12 @@ Energiegemeinschaftsverwaltung, Mitgliedschaften und Tarife.
### Backend (Spring Boot)
- **Java 21**, Spring Boot 4.0.6
- **Datenbank:** H2 im PostgreSQL-Modus (`jdbc:h2:file:./eegportal;MODE=PostgreSQL`)
- **Datenbank:** H2 (Dev) / PostgreSQL 16 (Prod)
- **ORM:** Spring Data JPA + Hibernate (`ddl-auto: update`)
- **Annotation Processing:** Lombok + MapStruct (mit `unmappedTargetPolicy=ERROR`)
- **Pakete:**
- `iam/` - Identitäts- und Zugriffsverwaltung (Auth, Users, Security)
- `community/` - Energiegemeinschaften, Mitgliedschaften, Messpunkte
- `community/` - Energiegemeinschaften, Mitgliedschaften, Messpunkte, Messdaten
- `mako/` - EDA-Kommunikation (Consent, Topologie)
- `tariff/` - Tarifverwaltung (nur Domain-Entity, noch kein Service)
- `dashboard/` - Dashboard-Statistiken
@ -156,3 +156,31 @@ fix(mako): handle consent rejected event correctly
docs: update AGENTS.md with conventional commits
test(iam): add UserService unit tests
```
## Deployment
### Docker (Produktion)
```bash
# .env erstellen
cp .env.example .env
nano .env
# Starten
docker compose up -d
# Logs
docker compose logs -f
```
### Docker (lokale Entwicklung)
```bash
# Mit H2 (Standard)
docker compose up -d
# Mit PostgreSQL
docker compose --profile postgres up -d
```
Siehe `DEPLOYMENT.md` für detaillierte Anleitung.

128
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,128 @@
# EEG Portal - Deployment Guide
## Quick Start (Local Development)
```bash
# Start with H2 database (default)
docker compose up -d
# Or with PostgreSQL
docker compose --profile postgres up -d
```
## Production Deployment on Hetzner Cloud
### 1. Server erstellen
1. Hetzner Cloud Console öffnen
2. Neuen Server erstellen:
- **Image:** Debian 12
- **Größe:** CX22 (2 vCPU, 4 GB RAM) - ~€4,59/Monat
- **Standort:** Falkenstein oder Nürnberg
- **SSH-Key:** Hochladen (empfohlen)
### 2. Server vorbereiten
```bash
# Auf Server verbinden
ssh root@<server-ip>
# Server-Setup-Skript ausführen
bash setup-server.sh
```
### 3. Projekt deployen
```bash
# Projekt klonen
git clone <your-repo-url>
cd eeg_portal
# .env erstellen
cp .env.example .env
nano .env # Werte eintragen
# Deploy starten
bash deploy.sh
```
### 4. SSL einrichten (optional)
```bash
# DNS muss auf Server-IP zeigen
bash setup-ssl.sh your-domain.at
```
## Environment Variables
| Variable | Beschreibung | Beispiel |
|---|---|---|
| `JWT_SECRET` | JWT-Schlüssel (required) | `super-secret-key-123` |
| `APP_FRONTEND_URL` | Frontend-URL (required) | `https://eeg-portal.at` |
| `APP_BACKEND_URL` | Backend-URL (required) | `https://eeg-portal.at` |
| `CORS_ORIGINS` | Erlaubte Origins (required) | `https://eeg-portal.at` |
| `POSTGRES_DB` | Datenbank-Name | `eegportal` |
| `POSTGRES_USER` | Datenbank-User | `eeg` |
| `POSTGRES_PASSWORD` | Datenbank-Passwort (required) | `starkes-passwort` |
| `MAIL_HOST` | SMTP-Server | `smtp.gmail.com` |
| `MAIL_PORT` | SMTP-Port | `587` |
| `MAIL_USERNAME` | SMTP-User | `admin@eeg-portal.at` |
| `MAIL_PASSWORD` | SMTP-Passwort | `app-passwort` |
## Services
| Service | Port | Beschreibung |
|---|---|---|
| **Frontend** | 80/443 | Nginx + Angular |
| **Backend** | 8080 | Spring Boot API |
| **Database** | 5432 | PostgreSQL 16 |
## Nützliche Befehle
```bash
# Logs anzeigen
docker compose logs -f
# Nur Backend-Logs
docker compose logs -f backend
# Neustart
docker compose restart
# Update
git pull
docker compose up -d --build
# Backup
docker compose exec db pg_dump -U eeg eegportal > backup.sql
# Restore
docker compose exec -T db psql -U eeg eegportal < backup.sql
```
## Troubleshooting
### Datenbank-Verbindung fehlgeschlagen
```bash
# Prüfen ob PostgreSQL läuft
docker compose ps db
# Logs anzeigen
docker compose logs db
```
### Backend startet nicht
```bash
# Backend-Logs
docker compose logs backend
# Prüfen ob JWT_SECRET gesetzt ist
docker compose exec backend env | grep JWT
```
### SSL-Zertifikat abgelaufen
```bash
# Zertifikat erneuern
certbot renew
docker compose restart frontend
```

68
Dockerfile Normal file
View file

@ -0,0 +1,68 @@
# ============================================================
# EEG Portal - Multi-stage Docker Build
# Stage 1: Frontend (Node) -> Stage 2: Backend (Maven) -> Stage 3: Runtime (JRE)
# ============================================================
# ---- Stage 1: Build Angular Frontend ----
FROM node:24-slim AS frontend-build
WORKDIR /app/eeg_frontend
COPY eeg_frontend/package.json eeg_frontend/package-lock.json* ./
RUN npm ci --prefer-offline
COPY eeg_frontend/ ./
RUN npm run build -- --configuration production
# ---- Stage 2: Build Spring Boot Backend ----
FROM maven:3.9-eclipse-temurin-21 AS backend-build
WORKDIR /app
# Copy parent POM and module POMs first (dependency caching)
COPY pom.xml ./
COPY eeg_backend/pom.xml eeg_backend/
COPY eeg_frontend/pom.xml eeg_frontend/
# Download dependencies (cached unless POMs change)
RUN mvn dependency:go-offline -pl eeg_backend -am -B
# Copy source code
COPY eeg_backend/src eeg_backend/src
# Copy Angular build output into Spring Boot static resources
COPY --from=frontend-build /app/eeg_frontend/dist/eeg_frontend/browser/ eeg_backend/src/main/resources/static/
# Build the backend JAR (skip tests in Docker)
RUN mvn package -pl eeg_backend -am -DskipTests -B
# ---- Stage 3: Runtime ----
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
# Install curl for health check
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -r eeg && useradd -r -g eeg -d /app -s /sbin/nologin eeg
# Copy the built JAR
COPY --from=backend-build /app/eeg_backend/target/*.jar app.jar
# Set ownership
RUN chown -R eeg:eeg /app
USER eeg
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "app.jar", \
"--spring.profiles.active=prod"]

62
deploy.sh Normal file
View file

@ -0,0 +1,62 @@
#!/bin/bash
# ============================================================
# EEG Portal - Deploy Script for Hetzner Cloud
# ============================================================
set -e
echo "=== EEG Portal Deployment ==="
echo ""
# Check if .env exists
if [ ! -f .env ]; then
echo "ERROR: .env file not found!"
echo "Please copy .env.example to .env and fill in the values."
echo ""
echo " cp .env.example .env"
echo " nano .env"
exit 1
fi
# Check required variables
source .env
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-super-secret-jwt-key-change-this" ]; then
echo "ERROR: JWT_SECRET must be set in .env"
exit 1
fi
if [ -z "$POSTGRES_PASSWORD" ] || [ "$POSTGRES_PASSWORD" = "your-strong-db-password-change-this" ]; then
echo "ERROR: POSTGRES_PASSWORD must be set in .env"
exit 1
fi
if [ -z "$APP_FRONTEND_URL" ] || [ "$APP_FRONTEND_URL" = "https://your-domain.at" ]; then
echo "ERROR: APP_FRONTEND_URL must be set in .env"
exit 1
fi
echo "Configuration OK!"
echo ""
# Stop existing containers
echo "Stopping existing containers..."
docker compose down
# Build and start
echo "Building and starting services..."
docker compose up -d --build
echo ""
echo "=== Deployment complete! ==="
echo ""
echo "Services:"
echo " - Frontend: http://localhost:${APP_PORT:-80}"
echo " - Backend: http://localhost:8080"
echo " - Database: localhost:5432"
echo ""
echo "To view logs:"
echo " docker compose logs -f"
echo ""
echo "To stop:"
echo " docker compose down"

74
docker-compose.yml Normal file
View file

@ -0,0 +1,74 @@
services:
db:
image: postgres:16-alpine
container_name: eeg-database
restart: unless-stopped
environment:
- POSTGRES_DB=${POSTGRES_DB:-eegportal}
- POSTGRES_USER=${POSTGRES_USER:-eeg}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
volumes:
- pg-data:/var/lib/postgresql/data
networks:
- eeg-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-eeg} -d ${POSTGRES_DB:-eegportal}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
backend:
build:
context: .
dockerfile: Dockerfile
container_name: eeg-backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
- SPRING_PROFILES_ACTIVE=prod
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET must be set}
- APP_FRONTEND_URL=${APP_FRONTEND_URL:?APP_FRONTEND_URL must be set}
- APP_BACKEND_URL=${APP_BACKEND_URL:?APP_BACKEND_URL must be set}
- CORS_ORIGINS=${CORS_ORIGINS:?CORS_ORIGINS must be set}
- DATABASE_URL=jdbc:postgresql://db:5432/${POSTGRES_DB:-eegportal}
- DATABASE_DRIVER=org.postgresql.Driver
- DATABASE_USERNAME=${POSTGRES_USER:-eeg}
- DATABASE_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
- MAIL_HOST=${MAIL_HOST:-}
- MAIL_PORT=${MAIL_PORT:-587}
- MAIL_USERNAME=${MAIL_USERNAME:-}
- MAIL_PASSWORD=${MAIL_PASSWORD:-}
- JAVA_OPTS=${JAVA_OPTS:--Xms256m -Xmx512m}
networks:
- eeg-network
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
frontend:
image: nginx:alpine
container_name: eeg-frontend
restart: unless-stopped
ports:
- "${APP_PORT:-80}:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
backend:
condition: service_healthy
networks:
- eeg-network
volumes:
pg-data:
driver: local
networks:
eeg-network:
driver: bridge

View file

@ -41,6 +41,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
@ -57,12 +61,24 @@
<version>0.2.0</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.3.0</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
@ -158,6 +174,16 @@
<artifactId>lombok</artifactId>
<version>1.18.38</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.6.3</version>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
@ -174,6 +200,16 @@
<artifactId>lombok</artifactId>
<version>1.18.38</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.6.3</version>
</path>
</annotationProcessorPaths>
</configuration>
</execution>

View file

@ -0,0 +1,92 @@
package at.mueller.eeg.backend.community.api;
import at.mueller.eeg.backend.common.security.CurrentUserId;
import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto;
import at.mueller.eeg.backend.community.api.dto.MeteringDataResponse;
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.DataSource;
import at.mueller.eeg.backend.community.domain.MeteringDataType;
import at.mueller.eeg.backend.community.service.MeteringDataService;
import at.mueller.eeg.backend.community.service.XlsxMeteringDataParser;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/community/metering-data")
@RequiredArgsConstructor
public class MeteringDataController {
private final MeteringDataService meteringDataService;
private final XlsxMeteringDataParser xlsxParser;
@PostMapping("/upload")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<MeteringDataUploadResponse> uploadMeteringData(
@CurrentUserId String userId,
@Valid @RequestBody MeteringDataUploadRequest request) {
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(
request, UUID.fromString(userId));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping(value = "/upload/xlsx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<MeteringDataUploadResponse> uploadXlsx(
@CurrentUserId String userId,
@RequestParam("file") MultipartFile file) throws IOException {
List<MeteringDataRecordDto> records = xlsxParser.parse(file.getInputStream());
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX,
file.getOriginalFilename(),
records
);
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(
request, UUID.fromString(userId));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{meteringPointId}")
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<List<MeteringDataResponse>> getMeteringData(
@CurrentUserId String userId,
@PathVariable UUID meteringPointId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
return ResponseEntity.ok(meteringDataService.getMeteringData(
UUID.fromString(userId), meteringPointId, from, to));
}
@GetMapping("/{meteringPointId}/by-type")
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<List<MeteringDataResponse>> getMeteringDataByType(
@CurrentUserId String userId,
@PathVariable UUID meteringPointId,
@RequestParam MeteringDataType dataType,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
return ResponseEntity.ok(meteringDataService.getMeteringDataByType(
UUID.fromString(userId), meteringPointId, dataType, from, to));
}
@GetMapping("/upload/{uploadId}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<MeteringDataResponse>> getUploadData(@PathVariable UUID uploadId) {
return ResponseEntity.ok(meteringDataService.getUploadData(uploadId));
}
}

View file

@ -0,0 +1,31 @@
package at.mueller.eeg.backend.community.api.dto;
import at.mueller.eeg.backend.community.domain.MeteringDataType;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.time.LocalDateTime;
public record MeteringDataRecordDto(
@NotBlank
@Pattern(regexp = "^AT[0-9]{31}$", message = "Muss eine gültige österreichische 33-stellige Zählpunktnummer sein")
String atNumber,
@NotNull
MeteringDataType dataType,
@NotNull
LocalDateTime intervalStart,
@NotNull
LocalDateTime intervalEnd,
@NotNull
@Min(0)
Double kwh,
Double readingValue
) {
}

View file

@ -0,0 +1,20 @@
package at.mueller.eeg.backend.community.api.dto;
import at.mueller.eeg.backend.community.domain.DataSource;
import at.mueller.eeg.backend.community.domain.MeteringDataType;
import java.time.LocalDateTime;
import java.util.UUID;
public record MeteringDataResponse(
UUID id,
UUID meteringPointId,
String atNumber,
MeteringDataType dataType,
LocalDateTime intervalStart,
LocalDateTime intervalEnd,
Double kwh,
Double readingValue,
DataSource source
) {
}

View file

@ -0,0 +1,21 @@
package at.mueller.eeg.backend.community.api.dto;
import at.mueller.eeg.backend.community.domain.DataSource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record MeteringDataUploadRequest(
@NotNull
DataSource source,
String fileName,
@NotEmpty
@Valid
List<MeteringDataRecordDto> records
) {
}

View file

@ -0,0 +1,21 @@
package at.mueller.eeg.backend.community.api.dto;
import at.mueller.eeg.backend.community.domain.UploadStatus;
import java.util.List;
import java.util.UUID;
public record MeteringDataUploadResponse(
UUID uploadId,
UploadStatus status,
Integer recordCount,
List<String> validationErrors
) {
public static MeteringDataUploadResponse success(UUID uploadId, Integer recordCount) {
return new MeteringDataUploadResponse(uploadId, UploadStatus.COMPLETED, recordCount, List.of());
}
public static MeteringDataUploadResponse validationFailed(List<String> errors) {
return new MeteringDataUploadResponse(null, UploadStatus.FAILED, 0, errors);
}
}

View file

@ -0,0 +1,7 @@
package at.mueller.eeg.backend.community.domain;
public enum DataSource {
EMAIL_XLSX,
API,
MANUAL
}

View file

@ -0,0 +1,60 @@
package at.mueller.eeg.backend.community.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "metering_data", indexes = {
@Index(name = "idx_metering_data_mp_interval", columnList = "metering_point_id, interval_start"),
@Index(name = "idx_metering_data_upload", columnList = "upload_id")
}, uniqueConstraints = {
@UniqueConstraint(columnNames = {"metering_point_id", "interval_start", "data_type"})
})
@Getter
@Setter
public class MeteringData {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "metering_point_id", nullable = false)
private MeteringPoint meteringPoint;
@Column(name = "upload_id", nullable = false)
private UUID uploadId;
@Enumerated(EnumType.STRING)
@Column(name = "data_type", nullable = false)
private MeteringDataType dataType;
@Column(name = "interval_start", nullable = false)
private LocalDateTime intervalStart;
@Column(name = "interval_end", nullable = false)
private LocalDateTime intervalEnd;
@Column(nullable = false)
private Double kwh;
@Column(name = "reading_value")
private Double readingValue;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private DataSource source;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
createdAt = Instant.now();
}
}

View file

@ -0,0 +1,7 @@
package at.mueller.eeg.backend.community.domain;
public enum MeteringDataType {
CONSUMPTION,
FEED_IN,
SELF_CONSUMPTION
}

View file

@ -0,0 +1,47 @@
package at.mueller.eeg.backend.community.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "metering_data_uploads")
@Getter
@Setter
public class MeteringDataUpload {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private DataSource source;
@Column(name = "file_name")
private String fileName;
@Column(nullable = false)
private Integer recordCount;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UploadStatus status;
@Column(name = "error_message")
private String errorMessage;
@Column(name = "uploaded_by")
private UUID uploadedBy;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
createdAt = Instant.now();
}
}

View file

@ -0,0 +1,8 @@
package at.mueller.eeg.backend.community.domain;
public enum UploadStatus {
PENDING,
PROCESSING,
COMPLETED,
FAILED
}

View file

@ -6,7 +6,7 @@ import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
@Mapper
@Mapper(componentModel = "spring")
public interface EnergyCommunityMapper {
@Mapping(target = "id", ignore = true)

View file

@ -0,0 +1,14 @@
package at.mueller.eeg.backend.community.mapper;
import at.mueller.eeg.backend.community.api.dto.MeteringDataResponse;
import at.mueller.eeg.backend.community.domain.MeteringData;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface MeteringDataMapper {
@Mapping(target = "meteringPointId", source = "meteringPoint.id")
@Mapping(target = "atNumber", source = "meteringPoint.atNumber")
MeteringDataResponse toResponse(MeteringData entity);
}

View file

@ -0,0 +1,23 @@
package at.mueller.eeg.backend.community.repository;
import at.mueller.eeg.backend.community.domain.MeteringData;
import at.mueller.eeg.backend.community.domain.MeteringDataType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID> {
List<MeteringData> findByMeteringPointIdAndIntervalStartBetween(
UUID meteringPointId, LocalDateTime from, LocalDateTime to);
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartBetween(
UUID meteringPointId, MeteringDataType dataType, LocalDateTime from, LocalDateTime to);
List<MeteringData> findByUploadId(UUID uploadId);
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStart(
UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart);
}

View file

@ -0,0 +1,9 @@
package at.mueller.eeg.backend.community.repository;
import at.mueller.eeg.backend.community.domain.MeteringDataUpload;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface MeteringDataUploadRepository extends JpaRepository<MeteringDataUpload, UUID> {
}

View file

@ -3,6 +3,7 @@ package at.mueller.eeg.backend.community.repository;
import at.mueller.eeg.backend.community.domain.MeteringPoint;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ -17,6 +18,8 @@ public interface MeteringPointRepository extends JpaRepository<MeteringPoint, UU
Optional<MeteringPoint> findByAtNumber(String atNumber);
List<MeteringPoint> findByAtNumberIn(Collection<String> atNumbers);
void deleteByUserId(UUID userId);
void deleteByIdAndUserId(UUID id, UUID userId);

View file

@ -0,0 +1,176 @@
package at.mueller.eeg.backend.community.service;
import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto;
import at.mueller.eeg.backend.community.api.dto.MeteringDataResponse;
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.mapper.MeteringDataMapper;
import at.mueller.eeg.backend.community.repository.MeteringDataRepository;
import at.mueller.eeg.backend.community.repository.MeteringDataUploadRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MeteringDataService {
private final MeteringDataRepository meteringDataRepository;
private final MeteringDataUploadRepository uploadRepository;
private final MeteringPointRepository meteringPointRepository;
private final MeteringDataMapper mapper;
@Transactional
public MeteringDataUploadResponse uploadMeteringData(MeteringDataUploadRequest request, UUID uploadedBy) {
List<String> validationErrors = validateRecords(request.records());
if (!validationErrors.isEmpty()) {
return MeteringDataUploadResponse.validationFailed(validationErrors);
}
MeteringDataUpload upload = new MeteringDataUpload();
upload.setSource(request.source());
upload.setFileName(request.fileName());
upload.setRecordCount(request.records().size());
upload.setStatus(UploadStatus.PROCESSING);
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()))
.toList();
deduplicateAndSave(entities);
savedUpload.setStatus(UploadStatus.COMPLETED);
uploadRepository.save(savedUpload);
return MeteringDataUploadResponse.success(savedUpload.getId(), entities.size());
}
public List<MeteringDataResponse> getMeteringData(UUID userId, UUID meteringPointId,
LocalDateTime from, LocalDateTime to) {
verifyOwnership(userId, meteringPointId);
return meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(meteringPointId, from, to)
.stream()
.map(mapper::toResponse)
.toList();
}
public List<MeteringDataResponse> getMeteringDataByType(UUID userId, UUID meteringPointId,
MeteringDataType dataType,
LocalDateTime from, LocalDateTime to) {
verifyOwnership(userId, meteringPointId);
return meteringDataRepository
.findByMeteringPointIdAndDataTypeAndIntervalStartBetween(meteringPointId, dataType, from, to)
.stream()
.map(mapper::toResponse)
.toList();
}
public List<MeteringDataResponse> getUploadData(UUID uploadId) {
return meteringDataRepository.findByUploadId(uploadId)
.stream()
.map(mapper::toResponse)
.toList();
}
private void verifyOwnership(UUID userId, UUID meteringPointId) {
meteringPointRepository.findById(meteringPointId)
.filter(mp -> mp.getUserId().equals(userId))
.orElseThrow(() -> new org.springframework.security.access.AccessDeniedException(
"Keine Berechtigung zum Zugriff auf diesen Zählpunkt"));
}
private List<String> validateRecords(List<MeteringDataRecordDto> records) {
List<String> errors = new ArrayList<>();
if (records == null || records.isEmpty()) {
errors.add("Keine Datensätze vorhanden");
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())) {
errors.add(prefix + "Zählpunkt " + record.atNumber() + " nicht im System vorhanden");
}
if (record.intervalEnd().isBefore(record.intervalStart())) {
errors.add(prefix + "Intervall-Ende liegt vor Intervall-Beginn");
}
if (record.kwh() < 0) {
errors.add(prefix + "kWh-Wert darf nicht negativ sein");
}
}
return errors;
}
private Map<String, MeteringPoint> resolveMeteringPoints(List<MeteringDataRecordDto> records) {
Set<String> atNumbers = records.stream()
.map(MeteringDataRecordDto::atNumber)
.collect(Collectors.toSet());
return meteringPointRepository.findByAtNumberIn(atNumbers).stream()
.collect(Collectors.toMap(MeteringPoint::getAtNumber, mp -> mp));
}
private MeteringData toEntity(MeteringDataRecordDto record, MeteringPoint meteringPoint,
UUID uploadId, DataSource source) {
MeteringData entity = new MeteringData();
entity.setMeteringPoint(meteringPoint);
entity.setUploadId(uploadId);
entity.setDataType(record.dataType());
entity.setIntervalStart(record.intervalStart());
entity.setIntervalEnd(record.intervalEnd());
entity.setKwh(record.kwh());
entity.setReadingValue(record.readingValue());
entity.setSource(source);
return entity;
}
private void deduplicateAndSave(List<MeteringData> entities) {
if (entities.isEmpty()) {
return;
}
Set<MeteringData> toDelete = new LinkedHashSet<>();
for (MeteringData entity : entities) {
meteringDataRepository
.findByMeteringPointIdAndDataTypeAndIntervalStart(
entity.getMeteringPoint().getId(),
entity.getDataType(),
entity.getIntervalStart())
.forEach(toDelete::add);
}
if (!toDelete.isEmpty()) {
meteringDataRepository.deleteAll(new ArrayList<>(toDelete));
}
meteringDataRepository.saveAll(entities);
}
}

View file

@ -0,0 +1,130 @@
package at.mueller.eeg.backend.community.service;
import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto;
import at.mueller.eeg.backend.community.domain.MeteringDataType;
import org.apache.poi.ss.usermodel.*;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Component
public class XlsxMeteringDataParser {
private static final int COL_AT_NUMBER = 0;
private static final int COL_DATA_TYPE = 1;
private static final int COL_INTERVAL_START = 2;
private static final int COL_INTERVAL_END = 3;
private static final int COL_KWH = 4;
private static final int COL_READING_VALUE = 5;
public List<MeteringDataRecordDto> parse(InputStream inputStream) throws IOException {
List<MeteringDataRecordDto> records = new ArrayList<>();
try (Workbook workbook = WorkbookFactory.create(inputStream)) {
Sheet sheet = workbook.getSheetAt(0);
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (row == null) {
continue;
}
String atNumber = getStringValue(row.getCell(COL_AT_NUMBER));
if (atNumber == null || atNumber.isBlank()) {
continue;
}
MeteringDataType dataType = parseDataType(getStringValue(row.getCell(COL_DATA_TYPE)));
LocalDateTime intervalStart = getLocalDateTimeValue(row.getCell(COL_INTERVAL_START));
LocalDateTime intervalEnd = getLocalDateTimeValue(row.getCell(COL_INTERVAL_END));
if (intervalStart == null || intervalEnd == null) {
continue;
}
Double kwh = getNumericValue(row.getCell(COL_KWH));
Double readingValue = getNumericValue(row.getCell(COL_READING_VALUE));
records.add(new MeteringDataRecordDto(
atNumber.trim(),
dataType,
intervalStart,
intervalEnd,
kwh != null ? kwh : 0.0,
readingValue
));
}
}
return records;
}
private String getStringValue(Cell cell) {
if (cell == null) {
return null;
}
if (cell.getCellType() == CellType.STRING) {
return cell.getStringCellValue();
}
if (cell.getCellType() == CellType.NUMERIC) {
return String.valueOf((long) cell.getNumericCellValue());
}
return null;
}
private Double getNumericValue(Cell cell) {
if (cell == null) {
return null;
}
if (cell.getCellType() == CellType.NUMERIC) {
return cell.getNumericCellValue();
}
if (cell.getCellType() == CellType.STRING) {
String value = cell.getStringCellValue().replace(",", ".").trim();
try {
return Double.parseDouble(value);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private LocalDateTime getLocalDateTimeValue(Cell cell) {
if (cell == null) {
return null;
}
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
return cell.getLocalDateTimeCellValue();
}
if (cell.getCellType() == CellType.STRING) {
try {
return LocalDateTime.parse(cell.getStringCellValue());
} catch (Exception e) {
try {
java.time.LocalDate date = java.time.LocalDate.parse(cell.getStringCellValue(),
java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy"));
return date.atStartOfDay();
} catch (Exception ex) {
return null;
}
}
}
return null;
}
private MeteringDataType parseDataType(String value) {
if (value == null) {
return MeteringDataType.CONSUMPTION;
}
return switch (value.trim().toLowerCase()) {
case "einspeisung", "erzeugung", "feed_in", "feed-in" -> MeteringDataType.FEED_IN;
case "eigenverbrauch", "self_consumption", "self-consumption" -> MeteringDataType.SELF_CONSUMPTION;
default -> MeteringDataType.CONSUMPTION;
};
}
}

View file

@ -56,6 +56,7 @@ public class SecurityConfig {
.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll()
.requestMatchers("/", "/index.html", "/assets/**", "/static/**", "/*.js", "/*.css", "/*.ico").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll()

View file

@ -25,6 +25,8 @@ public interface UserMapper {
@Mapping(target = "authorities", ignore = true)
@Mapping(target = "emailVerified", ignore = true)
@Mapping(target = "verificationToken", ignore = true)
@Mapping(target = "passwordResetToken", ignore = true)
@Mapping(target = "passwordResetTokenExpiry", ignore = true)
User mapToEntity(IamDtos.RegistrationRequest request, @MappingTarget User user);

View file

@ -0,0 +1,57 @@
package at.mueller.eeg.backend.tariff.api;
import at.mueller.eeg.backend.common.security.CurrentUserId;
import at.mueller.eeg.backend.tariff.api.dto.TariffInviteRequest;
import at.mueller.eeg.backend.tariff.api.dto.TariffInviteResponse;
import at.mueller.eeg.backend.tariff.service.TariffInviteService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/tariffs/invites")
@RequiredArgsConstructor
public class TariffInviteController {
private final TariffInviteService tariffInviteService;
@PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<TariffInviteResponse> createInvite(
@CurrentUserId String userId,
@Valid @RequestBody TariffInviteRequest request) {
TariffInviteResponse response = tariffInviteService.createInvite(UUID.fromString(userId), request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping(value = "/pending", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<List<TariffInviteResponse>> getPendingInvites(
@CurrentUserId String userId) {
return ResponseEntity.ok(tariffInviteService.getPendingInvitesForConsumer(UUID.fromString(userId)));
}
@PostMapping(value = "/{inviteId}/accept", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<TariffInviteResponse> acceptInvite(
@CurrentUserId String userId,
@PathVariable UUID inviteId) {
TariffInviteResponse response = tariffInviteService.acceptInvite(UUID.fromString(userId), inviteId);
return ResponseEntity.ok(response);
}
@GetMapping(value = "/community/{communityId}", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<List<TariffInviteResponse>> getInvitesForCommunity(
@CurrentUserId String userId,
@PathVariable UUID communityId) {
return ResponseEntity.ok(tariffInviteService.getInvitesForProducer(UUID.fromString(userId), communityId));
}
}

View file

@ -0,0 +1,11 @@
package at.mueller.eeg.backend.tariff.api.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.UUID;
public record TariffInviteRequest(
@NotNull UUID consumerUserId,
@NotNull UUID energyCommunityId,
@Size(max = 500) String note
) {}

View file

@ -0,0 +1,11 @@
package at.mueller.eeg.backend.tariff.api.dto;
import at.mueller.eeg.backend.tariff.domain.InviteStatus;
import java.time.LocalDateTime;
import java.util.UUID;
public record TariffInviteResponse(
UUID id, UUID producerUserId, UUID consumerUserId, UUID energyCommunityId,
String code, String note, InviteStatus status,
LocalDateTime expiresAt, LocalDateTime acceptedAt, LocalDateTime createdAt
) {}

View file

@ -0,0 +1,7 @@
package at.mueller.eeg.backend.tariff.domain;
public enum InviteStatus {
PENDING,
ACCEPTED,
EXPIRED
}

View file

@ -0,0 +1,33 @@
package at.mueller.eeg.backend.tariff.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "tariff_invites")
@Getter @Setter
public class TariffInvite {
@Id @GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "producer_user_id", nullable = false)
private UUID producerUserId;
@Column(name = "consumer_user_id", nullable = false)
private UUID consumerUserId;
@Column(name = "energy_community_id", nullable = false)
private UUID energyCommunityId;
@Column(nullable = false, unique = true, length = 6)
private String code;
@Column(length = 500)
private String note;
@Enumerated(EnumType.STRING)
private InviteStatus status;
private LocalDateTime expiresAt;
private LocalDateTime acceptedAt;
@Column(name = "created_at")
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() { createdAt = LocalDateTime.now(); }
}

View file

@ -0,0 +1,19 @@
package at.mueller.eeg.backend.tariff.repository;
import at.mueller.eeg.backend.tariff.domain.InviteStatus;
import at.mueller.eeg.backend.tariff.domain.TariffInvite;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface TariffInviteRepository extends JpaRepository<TariffInvite, UUID> {
Optional<TariffInvite> findByCode(String code);
List<TariffInvite> findByConsumerUserIdAndStatus(UUID consumerUserId, InviteStatus status);
List<TariffInvite> findByStatus(InviteStatus status);
List<TariffInvite> findByProducerUserIdAndEnergyCommunityId(UUID producerUserId, UUID communityId);
long countByProducerUserIdAndCreatedAtAfter(UUID userId, LocalDateTime since);
boolean existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus(
UUID producerUserId, UUID consumerUserId, UUID communityId, InviteStatus status);
}

View file

@ -0,0 +1,159 @@
package at.mueller.eeg.backend.tariff.service;
import at.mueller.eeg.backend.community.domain.MembershipStatus;
import at.mueller.eeg.backend.community.repository.MembershipRepository;
import at.mueller.eeg.backend.tariff.api.dto.TariffInviteRequest;
import at.mueller.eeg.backend.tariff.api.dto.TariffInviteResponse;
import at.mueller.eeg.backend.tariff.domain.InviteStatus;
import at.mueller.eeg.backend.tariff.domain.TariffInvite;
import at.mueller.eeg.backend.tariff.repository.TariffInviteRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class TariffInviteService {
private static final int MAX_INVITES_PER_DAY = 5;
private static final int INVITE_EXPIRY_DAYS = 7;
private static final int CODE_LENGTH = 6;
private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private final TariffInviteRepository tariffInviteRepository;
private final MembershipRepository membershipRepository;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
@Transactional
public TariffInviteResponse createInvite(UUID producerUserId, TariffInviteRequest request) {
if (!membershipRepository.isActiveMemberOfCommunity(producerUserId, request.energyCommunityId())) {
throw new IllegalStateException("Produzent ist kein Mitglied der Energiegemeinschaft.");
}
if (!membershipRepository.isActiveMemberOfCommunity(request.consumerUserId(), request.energyCommunityId())) {
throw new IllegalStateException("Konsument ist kein Mitglied der Energiegemeinschaft.");
}
if (producerUserId.equals(request.consumerUserId())) {
throw new IllegalStateException("Produzent und Konsument können nicht identisch sein.");
}
if (tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus(
producerUserId, request.consumerUserId(), request.energyCommunityId(), InviteStatus.PENDING)) {
throw new IllegalStateException("Es existiert bereits eine offene Einladung für diesen Benutzer in dieser Energiegemeinschaft.");
}
LocalDateTime since = LocalDateTime.now().minusDays(1);
long todayCount = tariffInviteRepository.countByProducerUserIdAndCreatedAtAfter(producerUserId, since);
if (todayCount >= MAX_INVITES_PER_DAY) {
throw new IllegalStateException("Tageslimit von " + MAX_INVITES_PER_DAY + " Einladungen erreicht.");
}
TariffInvite invite = new TariffInvite();
invite.setProducerUserId(producerUserId);
invite.setConsumerUserId(request.consumerUserId());
invite.setEnergyCommunityId(request.energyCommunityId());
invite.setCode(generateUniqueCode());
invite.setNote(request.note());
invite.setStatus(InviteStatus.PENDING);
invite.setExpiresAt(LocalDateTime.now().plusDays(INVITE_EXPIRY_DAYS));
TariffInvite saved = tariffInviteRepository.save(invite);
log.info("Tarif-Einladung erstellt: producer={}, consumer={}, community={}, code={}",
producerUserId, request.consumerUserId(), request.energyCommunityId(), saved.getCode());
return toResponse(saved);
}
@Transactional
public TariffInviteResponse acceptInvite(UUID consumerUserId, UUID inviteId) {
TariffInvite invite = tariffInviteRepository.findById(inviteId)
.orElseThrow(() -> new IllegalArgumentException("Einladung nicht gefunden: " + inviteId));
if (!invite.getConsumerUserId().equals(consumerUserId)) {
throw new org.springframework.security.access.AccessDeniedException(
"Keine Berechtigung zum Annehmen dieser Einladung.");
}
if (invite.getStatus() != InviteStatus.PENDING) {
throw new IllegalStateException("Einladung ist nicht mehr aktiv.");
}
if (invite.getExpiresAt().isBefore(LocalDateTime.now())) {
invite.setStatus(InviteStatus.EXPIRED);
tariffInviteRepository.save(invite);
throw new IllegalStateException("Einladung ist abgelaufen.");
}
invite.setStatus(InviteStatus.ACCEPTED);
invite.setAcceptedAt(LocalDateTime.now());
TariffInvite saved = tariffInviteRepository.save(invite);
log.info("Tarif-Einladung angenommen: id={}, consumer={}", inviteId, consumerUserId);
return toResponse(saved);
}
public List<TariffInviteResponse> getPendingInvitesForConsumer(UUID consumerUserId) {
return tariffInviteRepository.findByConsumerUserIdAndStatus(consumerUserId, InviteStatus.PENDING).stream()
.filter(invite -> invite.getExpiresAt().isAfter(LocalDateTime.now()))
.map(this::toResponse)
.toList();
}
public List<TariffInviteResponse> getInvitesForProducer(UUID producerUserId, UUID communityId) {
return tariffInviteRepository.findByProducerUserIdAndEnergyCommunityId(producerUserId, communityId).stream()
.map(this::toResponse)
.toList();
}
@Transactional
public void cleanupExpiredInvites() {
List<TariffInvite> expired = tariffInviteRepository.findByStatus(InviteStatus.PENDING).stream()
.filter(invite -> invite.getExpiresAt().isBefore(LocalDateTime.now()))
.toList();
for (TariffInvite invite : expired) {
invite.setStatus(InviteStatus.EXPIRED);
tariffInviteRepository.save(invite);
}
if (!expired.isEmpty()) {
log.info("Abgelaufene Einladungen bereinigt: {}", expired.size());
}
}
private String generateUniqueCode() {
String code;
do {
code = generateCode();
} while (tariffInviteRepository.findByCode(code).isPresent());
return code;
}
private String generateCode() {
StringBuilder sb = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(CODE_CHARS.charAt(SECURE_RANDOM.nextInt(CODE_CHARS.length())));
}
return sb.toString();
}
private TariffInviteResponse toResponse(TariffInvite invite) {
return new TariffInviteResponse(
invite.getId(),
invite.getProducerUserId(),
invite.getConsumerUserId(),
invite.getEnergyCommunityId(),
invite.getCode(),
invite.getNote(),
invite.getStatus(),
invite.getExpiresAt(),
invite.getAcceptedAt(),
invite.getCreatedAt()
);
}
}

View file

@ -12,8 +12,10 @@ import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffResponse;
import at.mueller.eeg.backend.tariff.api.dto.UserTariffRequest;
import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse;
import at.mueller.eeg.backend.tariff.domain.CommunityTariff;
import at.mueller.eeg.backend.tariff.domain.InviteStatus;
import at.mueller.eeg.backend.tariff.domain.UserTariff;
import at.mueller.eeg.backend.tariff.repository.CommunityTariffRepository;
import at.mueller.eeg.backend.tariff.repository.TariffInviteRepository;
import at.mueller.eeg.backend.tariff.repository.UserTariffRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -32,6 +34,7 @@ public class TariffService {
private final CommunityTariffRepository communityTariffRepository;
private final UserTariffRepository userTariffRepository;
private final TariffInviteRepository tariffInviteRepository;
private final MeteringPointRepository meteringPointRepository;
private final MembershipRepository membershipRepository;
private final ApplicationEventPublisher eventPublisher;
@ -116,6 +119,12 @@ public class TariffService {
throw new IllegalStateException("Ziel-Benutzer ist kein Mitglied der Energiegemeinschaft.");
}
if (!tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus(
sourceUserId, targetUserId, request.energyCommunityId(), InviteStatus.ACCEPTED)) {
throw new IllegalStateException(
"Zwischen Produzent und Konsument muss eine angenommene Einladung bestehen.");
}
UserTariff tariff = new UserTariff();
tariff.setEnergyCommunityId(request.energyCommunityId());
tariff.setSourceMeteringPointId(request.sourceMeteringPointId());

View file

@ -0,0 +1,54 @@
spring:
datasource:
url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/eegportal}
driver-class-name: ${DATABASE_DRIVER:org.postgresql.Driver}
username: ${DATABASE_USERNAME:eeg}
password: ${DATABASE_PASSWORD:}
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
mail:
host: ${MAIL_HOST:}
port: ${MAIL_PORT:587}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
jwt:
secret: ${JWT_SECRET}
app:
frontend-url: ${APP_FRONTEND_URL}
backend-url: ${APP_BACKEND_URL}
cors-origins: ${CORS_ORIGINS}
server:
port: ${SERVER_PORT:8080}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
logging:
level:
at.mueller.eeg: INFO
org.springframework.security: WARN
org.hibernate.SQL: WARN

View file

@ -0,0 +1,294 @@
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.mapper.MeteringDataMapper;
import at.mueller.eeg.backend.community.repository.MeteringDataRepository;
import at.mueller.eeg.backend.community.repository.MeteringDataUploadRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MeteringDataServiceTest {
@Mock
private MeteringDataRepository meteringDataRepository;
@Mock
private MeteringDataUploadRepository uploadRepository;
@Mock
private MeteringPointRepository meteringPointRepository;
@Mock
private MeteringDataMapper mapper;
@InjectMocks
private MeteringDataService meteringDataService;
private MeteringPoint testMeteringPoint;
private String testAtNumber;
private UUID testUserId;
@BeforeEach
void setUp() {
testAtNumber = "AT0010000000000000000000001234567";
testUserId = UUID.randomUUID();
testMeteringPoint = new MeteringPoint();
testMeteringPoint.setId(UUID.randomUUID());
testMeteringPoint.setUserId(testUserId);
testMeteringPoint.setAtNumber(testAtNumber);
testMeteringPoint.setType(PointType.CONSUMER);
testMeteringPoint.setMakoState(MakoState.ACTIVE);
}
@Test
void uploadMeteringData_successfulUpload() {
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.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());
assertNotNull(response);
assertEquals(UploadStatus.COMPLETED, response.status());
assertEquals(1, response.recordCount());
assertTrue(response.validationErrors().isEmpty());
}
@Test
void uploadMeteringData_validationFailsForUnknownAtNumber() {
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(Collections.emptyList());
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
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());
assertEquals(0, response.recordCount());
assertFalse(response.validationErrors().isEmpty());
assertTrue(response.validationErrors().get(0).contains("nicht im System vorhanden"));
}
@Test
void uploadMeteringData_validationFailsForEmptyRecords() {
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX,
"test.xlsx",
Collections.emptyList()
);
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID());
assertEquals(UploadStatus.FAILED, response.status());
assertFalse(response.validationErrors().isEmpty());
}
@Test
void uploadMeteringData_validationFailsForNegativeKwh() {
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint));
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 15),
-1.0,
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("negativ"));
}
@Test
void uploadMeteringData_validationFailsForInvalidInterval() {
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint));
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 15),
LocalDateTime.of(2026, 1, 1, 0, 0),
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("Intervall-Ende"));
}
@Test
void uploadMeteringData_multipleRecords() {
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,
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,
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",
records
);
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID());
assertEquals(UploadStatus.COMPLETED, response.status());
assertEquals(3, response.recordCount());
verify(meteringDataRepository).saveAll(any());
}
@Test
void uploadMeteringData_setsSourceFromRequest() {
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 -> {
@SuppressWarnings("unchecked")
List<MeteringData> entities = invocation.getArgument(0);
entities.forEach(e -> assertEquals(DataSource.EMAIL_XLSX, e.getSource()));
return entities;
});
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
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)
);
meteringDataService.uploadMeteringData(request, UUID.randomUUID());
verify(meteringDataRepository).saveAll(any());
}
@Test
void getMeteringData_allowsOwner() {
when(meteringPointRepository.findById(testMeteringPoint.getId()))
.thenReturn(Optional.of(testMeteringPoint));
when(meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(
any(), any(), any())).thenReturn(Collections.emptyList());
meteringDataService.getMeteringData(testUserId, testMeteringPoint.getId(),
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59));
verify(meteringDataRepository).findByMeteringPointIdAndIntervalStartBetween(
any(), any(), any());
}
@Test
void getMeteringData_rejectsNonOwner() {
UUID otherUserId = UUID.randomUUID();
when(meteringPointRepository.findById(testMeteringPoint.getId()))
.thenReturn(Optional.of(testMeteringPoint));
assertThrows(AccessDeniedException.class, () ->
meteringDataService.getMeteringData(otherUserId, testMeteringPoint.getId(),
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59)));
}
@Test
void getMeteringDataByType_rejectsNonOwner() {
UUID otherUserId = UUID.randomUUID();
when(meteringPointRepository.findById(testMeteringPoint.getId()))
.thenReturn(Optional.of(testMeteringPoint));
assertThrows(AccessDeniedException.class, () ->
meteringDataService.getMeteringDataByType(otherUserId, testMeteringPoint.getId(),
MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59)));
}
}

View file

@ -0,0 +1,177 @@
package at.mueller.eeg.backend.community.service;
import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto;
import at.mueller.eeg.backend.community.domain.MeteringDataType;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class XlsxMeteringDataParserTest {
private XlsxMeteringDataParser parser;
@BeforeEach
void setUp() {
parser = new XlsxMeteringDataParser();
}
@Test
void parse_validXlsxWithTwoRecords() throws IOException {
byte[] xlsxBytes = createTestXlsx(
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", ""},
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:15", "2026-01-01T00:30", "0.80", ""}
);
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(2, records.size());
MeteringDataRecordDto first = records.get(0);
assertEquals("AT0010000000000000000000001234567", first.atNumber());
assertEquals(MeteringDataType.CONSUMPTION, first.dataType());
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0), first.intervalStart());
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 15), first.intervalEnd());
assertEquals(1.25, first.kwh(), 0.001);
MeteringDataRecordDto second = records.get(1);
assertEquals(0.80, second.kwh(), 0.001);
}
@Test
void parse_einspeisungMapsToFeedIn() throws IOException {
byte[] xlsxBytes = createTestXlsx(
new String[]{"AT0010000000000000000000001234567", "Einspeisung", "2026-01-01T00:00", "2026-01-01T00:15", "2.50", ""}
);
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(1, records.size());
assertEquals(MeteringDataType.FEED_IN, records.get(0).dataType());
}
@Test
void parse_eigenverbrauchMapsToSelfConsumption() throws IOException {
byte[] xlsxBytes = createTestXlsx(
new String[]{"AT0010000000000000000000001234567", "Eigenverbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "0.50", ""}
);
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(1, records.size());
assertEquals(MeteringDataType.SELF_CONSUMPTION, records.get(0).dataType());
}
@Test
void parse_blankAtNumberRowIsSkipped() throws IOException {
byte[] xlsxBytes = createTestXlsx(
new String[]{"", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", ""},
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", ""}
);
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(1, records.size());
assertEquals("AT0010000000000000000000001234567", records.get(0).atNumber());
}
@Test
void parse_commaDecimalIsHandled() throws IOException {
byte[] xlsxBytes = createTestXlsx(
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1,25", ""}
);
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(1, records.size());
assertEquals(1.25, records.get(0).kwh(), 0.001);
}
@Test
void parse_withReadingValue() throws IOException {
byte[] xlsxBytes = createTestXlsx(
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", "12345.67"}
);
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(1, records.size());
assertEquals(12345.67, records.get(0).readingValue(), 0.01);
}
@Test
void parse_emptySheetReturnsEmptyList() throws IOException {
byte[] xlsxBytes = createTestXlsx();
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertTrue(records.isEmpty());
}
@Test
void parse_defaultDataTypeIsConsumption() throws IOException {
byte[] xlsxBytes = createTestXlsx(
new String[]{"AT0010000000000000000000001234567", "Unbekannt", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", ""}
);
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(1, records.size());
assertEquals(MeteringDataType.CONSUMPTION, records.get(0).dataType());
}
@Test
void parse_nullDateRowIsSkipped() throws IOException {
byte[] xlsxBytes = createTestXlsx(
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "", "2026-01-01T00:15", "1.25", ""},
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", ""}
);
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(1, records.size());
assertEquals("AT0010000000000000000000001234567", records.get(0).atNumber());
}
private byte[] createTestXlsx(String[]... dataRows) throws IOException {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("Messdaten");
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");
for (int i = 0; i < dataRows.length; i++) {
Row row = sheet.createRow(i + 1);
String[] data = dataRows[i];
for (int j = 0; j < data.length; j++) {
Cell cell = row.createCell(j);
if (j == 4 && !data[j].isEmpty()) {
cell.setCellValue(data[j].replace(",", "."));
} else if (j == 5 && !data[j].isEmpty()) {
cell.setCellValue(data[j].replace(",", "."));
} else {
cell.setCellValue(data[j]);
}
}
}
workbook.write(out);
return out.toByteArray();
}
}
}

View file

@ -15,8 +15,10 @@ import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffResponse;
import at.mueller.eeg.backend.tariff.api.dto.UserTariffRequest;
import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse;
import at.mueller.eeg.backend.tariff.domain.CommunityTariff;
import at.mueller.eeg.backend.tariff.domain.InviteStatus;
import at.mueller.eeg.backend.tariff.domain.UserTariff;
import at.mueller.eeg.backend.tariff.repository.CommunityTariffRepository;
import at.mueller.eeg.backend.tariff.repository.TariffInviteRepository;
import at.mueller.eeg.backend.tariff.repository.UserTariffRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -46,6 +48,8 @@ class TariffServiceTest {
@Mock
private UserTariffRepository userTariffRepository;
@Mock
private TariffInviteRepository tariffInviteRepository;
@Mock
private MeteringPointRepository meteringPointRepository;
@Mock
private MembershipRepository membershipRepository;
@ -181,6 +185,8 @@ class TariffServiceTest {
.thenReturn(Optional.of(communityTariff));
when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true);
when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true);
when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus(
sourceUserId, targetUserId, communityId, InviteStatus.ACCEPTED)).thenReturn(true);
UserTariff savedTariff = new UserTariff();
savedTariff.setId(UUID.randomUUID());
@ -508,6 +514,8 @@ class TariffServiceTest {
.thenReturn(Optional.of(communityTariff));
when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true);
when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true);
when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus(
sourceUserId, targetUserId, communityId, InviteStatus.ACCEPTED)).thenReturn(true);
UserTariff savedTariff = new UserTariff();
savedTariff.setId(UUID.randomUUID());
@ -590,6 +598,8 @@ class TariffServiceTest {
.thenReturn(Optional.of(communityTariff));
when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true);
when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true);
when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus(
sourceUserId, targetUserId, communityId, InviteStatus.ACCEPTED)).thenReturn(true);
UserTariff savedTariff = new UserTariff();
savedTariff.setId(UUID.randomUUID());

View file

@ -323,6 +323,43 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/UserTariffResponse"
/api/tariffs/invites:
post:
tags:
- tariff-invite-controller
operationId: createInvite
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/TariffInviteRequest"
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/TariffInviteResponse"
/api/tariffs/invites/{inviteId}/accept:
post:
tags:
- tariff-invite-controller
operationId: acceptInvite
parameters:
- name: inviteId
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/TariffInviteResponse"
/api/community/metering-points:
get:
tags:
@ -354,6 +391,47 @@ paths:
'*/*':
schema:
$ref: "#/components/schemas/MeteringPointResponse"
/api/community/metering-data/upload:
post:
tags:
- metering-data-controller
operationId: uploadMeteringData
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/MeteringDataUploadRequest"
required: true
responses:
"200":
description: OK
content:
'*/*':
schema:
$ref: "#/components/schemas/MeteringDataUploadResponse"
/api/community/metering-data/upload/xlsx:
post:
tags:
- metering-data-controller
operationId: uploadXlsx
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
required:
- file
responses:
"200":
description: OK
content:
'*/*':
schema:
$ref: "#/components/schemas/MeteringDataUploadResponse"
/api/community/memberships/request:
post:
tags:
@ -505,6 +583,41 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/EnergyCommunityDto"
/api/tariffs/invites/pending:
get:
tags:
- tariff-invite-controller
operationId: getPendingInvites
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TariffInviteResponse"
/api/tariffs/invites/community/{communityId}:
get:
tags:
- tariff-invite-controller
operationId: getInvitesForCommunity
parameters:
- name: communityId
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TariffInviteResponse"
/api/tariffs/community/{communityId}:
get:
tags:
@ -612,6 +725,102 @@ paths:
type: array
items:
$ref: "#/components/schemas/MeteringPointResponse"
/api/community/metering-data/{meteringPointId}:
get:
tags:
- metering-data-controller
operationId: getMeteringData
parameters:
- name: meteringPointId
in: path
required: true
schema:
type: string
format: uuid
- name: from
in: query
required: true
schema:
type: string
format: date-time
- name: to
in: query
required: true
schema:
type: string
format: date-time
responses:
"200":
description: OK
content:
'*/*':
schema:
type: array
items:
$ref: "#/components/schemas/MeteringDataResponse"
/api/community/metering-data/{meteringPointId}/by-type:
get:
tags:
- metering-data-controller
operationId: getMeteringDataByType
parameters:
- name: meteringPointId
in: path
required: true
schema:
type: string
format: uuid
- name: dataType
in: query
required: true
schema:
type: string
enum:
- CONSUMPTION
- FEED_IN
- SELF_CONSUMPTION
- name: from
in: query
required: true
schema:
type: string
format: date-time
- name: to
in: query
required: true
schema:
type: string
format: date-time
responses:
"200":
description: OK
content:
'*/*':
schema:
type: array
items:
$ref: "#/components/schemas/MeteringDataResponse"
/api/community/metering-data/upload/{uploadId}:
get:
tags:
- metering-data-controller
operationId: getUploadData
parameters:
- name: uploadId
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: OK
content:
'*/*':
schema:
type: array
items:
$ref: "#/components/schemas/MeteringDataResponse"
/api/community/memberships/me:
get:
tags:
@ -913,6 +1122,56 @@ components:
updatedAt:
type: string
format: date-time
TariffInviteRequest:
type: object
properties:
consumerUserId:
type: string
format: uuid
energyCommunityId:
type: string
format: uuid
note:
type: string
maxLength: 500
minLength: 0
required:
- consumerUserId
- energyCommunityId
TariffInviteResponse:
type: object
properties:
id:
type: string
format: uuid
producerUserId:
type: string
format: uuid
consumerUserId:
type: string
format: uuid
energyCommunityId:
type: string
format: uuid
code:
type: string
note:
type: string
status:
type: string
enum:
- PENDING
- ACCEPTED
- EXPIRED
expiresAt:
type: string
format: date-time
acceptedAt:
type: string
format: date-time
createdAt:
type: string
format: date-time
CreateMeteringPointRequest:
type: object
properties:
@ -928,6 +1187,77 @@ components:
required:
- atNumber
- type
MeteringDataRecordDto:
type: object
properties:
atNumber:
type: string
minLength: 1
pattern: "^AT[0-9]{31}$"
dataType:
type: string
enum:
- CONSUMPTION
- FEED_IN
- SELF_CONSUMPTION
intervalStart:
type: string
format: date-time
intervalEnd:
type: string
format: date-time
kwh:
type: number
format: double
minimum: 0
readingValue:
type: number
format: double
required:
- atNumber
- dataType
- intervalEnd
- intervalStart
- kwh
MeteringDataUploadRequest:
type: object
properties:
source:
type: string
enum:
- EMAIL_XLSX
- API
- MANUAL
fileName:
type: string
records:
type: array
items:
$ref: "#/components/schemas/MeteringDataRecordDto"
minItems: 1
required:
- records
- source
MeteringDataUploadResponse:
type: object
properties:
uploadId:
type: string
format: uuid
status:
type: string
enum:
- PENDING
- PROCESSING
- COMPLETED
- FAILED
recordCount:
type: integer
format: int32
validationErrors:
type: array
items:
type: string
MembershipRequest:
type: object
properties:
@ -1093,6 +1423,9 @@ components:
pendingUsers:
type: integer
format: int64
pendingMemberships:
type: integer
format: int64
totalActiveMemberships:
type: integer
format: int64
@ -1112,6 +1445,41 @@ components:
- BEG
edaEcId:
type: string
MeteringDataResponse:
type: object
properties:
id:
type: string
format: uuid
meteringPointId:
type: string
format: uuid
atNumber:
type: string
dataType:
type: string
enum:
- CONSUMPTION
- FEED_IN
- SELF_CONSUMPTION
intervalStart:
type: string
format: date-time
intervalEnd:
type: string
format: date-time
kwh:
type: number
format: double
readingValue:
type: number
format: double
source:
type: string
enum:
- EMAIL_XLSX
- API
- MANUAL
MembershipResponse:
type: object
properties:

67
nginx-ssl.conf Normal file
View file

@ -0,0 +1,67 @@
server {
listen 80;
server_name your-domain.at;
# Redirect HTTP to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.at;
# SSL configuration (Certbot will generate these)
ssl_certificate /etc/letsencrypt/live/your-domain.at/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.at/privkey.pem;
# SSL security settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Frontend static files
root /usr/share/nginx/html;
index index.html;
# API proxy to backend
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
# Swagger/OpenAPI proxy
location /v3/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Angular SPA - serve index.html for all non-file routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

49
nginx.conf Normal file
View file

@ -0,0 +1,49 @@
server {
listen 80;
server_name _;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Frontend static files
root /usr/share/nginx/html;
index index.html;
# API proxy to backend
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
# Swagger/OpenAPI proxy
location /v3/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Angular SPA - serve index.html for all non-file routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

54
setup-server.sh Normal file
View file

@ -0,0 +1,54 @@
#!/bin/bash
# ============================================================
# EEG Portal - Hetzner Server Setup Script
# Run this on a fresh Debian/Ubuntu server
# ============================================================
set -e
echo "=== Hetzner Server Setup for EEG Portal ==="
echo ""
# Update system
echo "Updating system packages..."
apt update && apt upgrade -y
# Install Docker
echo "Installing Docker..."
apt install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Enable Docker
systemctl enable docker
systemctl start docker
# Install Git
echo "Installing Git..."
apt install -y git
# Install Certbot for SSL
echo "Installing Certbot..."
apt install -y certbot
echo ""
echo "=== Server setup complete! ==="
echo ""
echo "Next steps:"
echo " 1. Clone your repository:"
echo " git clone <your-repo-url>"
echo " cd eeg_portal"
echo ""
echo " 2. Create .env file:"
echo " cp .env.example .env"
echo " nano .env"
echo ""
echo " 3. Start the application:"
echo " bash deploy.sh"
echo ""
echo " 4. For SSL (after DNS is configured):"
echo " certbot certonly --standalone -d your-domain.at"

49
setup-ssl.sh Normal file
View file

@ -0,0 +1,49 @@
#!/bin/bash
# ============================================================
# EEG Portal - SSL Setup Script
# Run this after DNS is configured and pointing to your server
# ============================================================
set -e
echo "=== SSL Setup for EEG Portal ==="
echo ""
# Check if domain is provided
if [ -z "$1" ]; then
echo "Usage: $0 <your-domain.at>"
echo "Example: $0 eeg-portal.at"
exit 1
fi
DOMAIN=$1
echo "Setting up SSL for: $DOMAIN"
echo ""
# Stop nginx container temporarily
echo "Stopping nginx container..."
docker stop eeg-frontend
# Get SSL certificate
echo "Getting SSL certificate from Let's Encrypt..."
certbot certonly --standalone -d $DOMAIN --non-interactive --agree-tos --email admin@$DOMAIN
# Update nginx config with domain
echo "Updating nginx configuration..."
sed -i "s/your-domain.at/$DOMAIN/g" nginx-ssl.conf
# Copy SSL config
echo "Activating SSL configuration..."
cp nginx-ssl.conf /etc/nginx/conf.d/default.conf
# Start nginx container
echo "Starting nginx container..."
docker start eeg-frontend
echo ""
echo "=== SSL setup complete! ==="
echo ""
echo "Your site is now available at: https://$DOMAIN"
echo ""
echo "Note: Certbot auto-renewal is set up. Certificate will be renewed automatically."