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:
parent
949e1e6c34
commit
975f76dbd3
45 changed files with 2573 additions and 4 deletions
31
.dockerignore
Normal file
31
.dockerignore
Normal 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
33
.env.example
Normal 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
9
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
34
AGENTS.md
34
AGENTS.md
|
|
@ -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
128
DEPLOYMENT.md
Normal 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
68
Dockerfile
Normal 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
62
deploy.sh
Normal 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
74
docker-compose.yml
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package at.mueller.eeg.backend.community.domain;
|
||||
|
||||
public enum DataSource {
|
||||
EMAIL_XLSX,
|
||||
API,
|
||||
MANUAL
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package at.mueller.eeg.backend.community.domain;
|
||||
|
||||
public enum MeteringDataType {
|
||||
CONSUMPTION,
|
||||
FEED_IN,
|
||||
SELF_CONSUMPTION
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package at.mueller.eeg.backend.community.domain;
|
||||
|
||||
public enum UploadStatus {
|
||||
PENDING,
|
||||
PROCESSING,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package at.mueller.eeg.backend.tariff.domain;
|
||||
|
||||
public enum InviteStatus {
|
||||
PENDING,
|
||||
ACCEPTED,
|
||||
EXPIRED
|
||||
}
|
||||
|
|
@ -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(); }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
54
eeg_backend/src/main/resources/application-prod.yml
Normal file
54
eeg_backend/src/main/resources/application-prod.yml
Normal 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
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
67
nginx-ssl.conf
Normal 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
49
nginx.conf
Normal 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
54
setup-server.sh
Normal 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
49
setup-ssl.sh
Normal 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."
|
||||
Loading…
Reference in a new issue