Compare commits

...

10 commits

Author SHA1 Message Date
83b6d503cc fix(frontend): Dockerfile for build 2026-07-27 13:19:26 +02:00
61968f7f82 fix(frontend): Dockerfile for build 2026-07-27 12:57:38 +02:00
3e33bb797a fix(frontend): Dockerfile for build 2026-07-27 12:53:46 +02:00
6dce65b39c fix(frontend): Dockerfile for build 2026-07-27 12:10:19 +02:00
dac44dcb7d fix(frontend): align @angular/platform-browser-dynamic version to ^21.2.0
Fixed peer dependency conflict by updating @angular/platform-browser-dynamic
from ^20.0.7 to ^21.2.0 to match the rest of the Angular 21 packages.
2026-07-25 17:52:59 +02:00
675c10b949 feat(mako): add admin-selectable EDA communication method (email vs messenger)
- Add EdaCommunicationMethod enum (EMAIL, MESSENGER)
- Add EdaSettings entity with DB-persisted communication method
- Add AdminEdaSettingsController (GET/PUT /api/admin/eda-settings)
- Add EdaCommunicationRouter that resolves the correct EdaConsentService
- Add MessengerEdaConsentService as placeholder (throws UnsupportedOperationException)
- Add AdminEdaSettingsComponent in frontend with radio button selection
- Route /dashboard/admin-eda-settings (ADMIN only)
- Update EdaCommunicationEventListener to use router
- Remove @Profile from EmailEdaConsentService/SimulatedEdaConsentService
- Use @Lazy for EdaMailSender dependency to support non-prod contexts
- Fix pre-existing deleteUserTariff API call mismatch
2026-07-25 15:29:58 +02:00
459f976b67 feat(mako): add email-based EDA communication
- Add EmailEdaConsentService for prod consent requests via SMTP
- Add EdaMailSender, EdaMailParser, EdaMailPoller for IMAP polling
- Polling interval: 15 min (configurable via eda.email.poll-interval-ms)
- Consumption data ingestion from XLSX email attachments
- Add @Profile('!prod') to SimulatedEda*Services as fallback
- Add EMAIL_AUTO to DataSource enum for automated email imports
- Add EDA email configuration to application-prod.yml and application-dev.yml
- Add unit tests for all new services
- Add docs/PLAN-EDA-KOMMUNIKATION.md with full implementation plan

All 232 tests passing.
2026-07-25 15:29:43 +02:00
6cb4ff7a4a fix(frontend): add missing provideEcharts() to app config causing empty dashboard chart
Root cause: ngx-echarts v18+ requires provideEcharts() in the app
config. Without it, NgxEchartsDirective silently fails to render
charts, causing the dashboard overview chart to appear empty.

Added provideEcharts() to app.config.ts and added
DashboardServiceIntegrationTest for the metering data overview endpoint.
2026-07-24 15:57:35 +02:00
0ef4f94933 fix(frontend): add error handling for dashboard overview metering data API
The empty error callback in loadMeteringData() silently swallowed API
failures, causing the chart to show 'Keine Daten' instead of the actual
error. Added proper error logging and user-facing error message.

Also added DashboardServiceTest for getMeteringDataOverview with
scenarios for data, no metering points, empty time range, and
multiple metering points.
2026-07-24 15:27:36 +02:00
cfe989c426 fix(frontend): append UTC offset to Instant date parameters in consumption dashboard
The consumption dashboard sent dates like '2026-06-24T00:00:00' without
timezone offset, which Spring could not parse into java.time.Instant.
Append 'Z' suffix to produce valid ISO-8601 UTC timestamps.
2026-07-24 15:03:22 +02:00
38 changed files with 2518 additions and 3169 deletions

View file

@ -6,11 +6,19 @@
# ---- Stage 1: Build Angular Frontend ----
FROM node:24-slim AS frontend-build
RUN apt-get update && apt-get install -y --no-install-recommends \
default-jre-headless \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app/eeg_frontend
COPY eeg_frontend/package.json eeg_frontend/package-lock.json* ./
RUN npm ci --prefer-offline
COPY eeg_frontend/openapi.yaml ./
RUN npx openapi-generator-cli generate -i ./openapi.yaml -g typescript-angular -o ./src/app/api
COPY eeg_frontend/ ./
RUN npm run build -- --configuration production

View file

@ -0,0 +1,227 @@
# Plan: E-Mail-basierte EDA-Kommunikation
## Ziel
Ersetze die aktuelle vollständige EDA-Simulation durch eine reale E-Mail-basierte Kommunikation mit dem EDA-Netzwerk (Netzbetreiber). Simulator bleibt als `@Profile("!prod")`-Fallback. Architektur erlaubt später einfachen Wechsel zu Messenger-API.
---
## Aktueller Stand
### Was existiert
- `EdaConsentService` (Interface) + `SimulatedEdaConsentService` (immer aktiv, kein @Profile)
- `EdaTopologyService` (Interface) + `SimulatedEdaTopologyService` (immer aktiv)
- `MailService` (Interface) für User-Verifizierung, nicht für EDA
- `spring-boot-starter-restclient` im pom.xml (nicht verwendet)
- `DataSource`-Enum hat `EMAIL_XLSX` aber kein automatisches E-Mail-Polling
- Kommentar in `EdaCommunicationEventListener:66`: "entweder via Webhook oder Mailbox"
### Was fehlt
- Keine reale EDA-Kommunikation (kein HTTP, kein E-Mail-Versand an EDA)
- Keine automatische Verbrauchsdaten-Ingestion via E-Mail
- Simulator hat kein `@Profile` - immer aktiv, auch in prod
---
## Architektur-Entscheidung: Strategy Pattern
```
┌─────────────────────────┐
│ EdaConsentService │ (Interface - bleibt unverändert)
│ EdaTopologyService │
└─────────┬───────────────┘
┌───────────────────┼───────────────────┐
│ │ │
┌─────────▼──────────┐ ┌─────▼───────────┐ ┌────▼──────────────┐
│ SimulatedEda* │ │ EmailEda* │ │ MessengerEda* │
@Profile("!prod") │ │ @Profile("prod")│ │ (später) │
└────────────────────┘ └─────────────────┘ └───────────────────┘
```
---
## Umsetzungs-Schritte
### Schritt 1: Simulator mit @Profile versehen
**Dateien:**
- `eeg_backend/.../mako/service/impl/SimulatedEdaConsentService.java`
- `eeg_backend/.../mako/service/impl/SimulatedEdaTopologyService.java`
**Änderung:**
```java
// Vorher:
@Service
public class SimulatedEdaConsentService implements EdaConsentService {
// Nachher:
@Profile("!prod")
@Service
public class SimulatedEdaConsentService implements EdaConsentService {
```
**Hinweis:** `@Profile("!prod")` statt `@Profile("dev")` - so sind die Simulatoren auch ohne Profil (z.B. in Tests) verfügbar. Die `EdaCommunicationEventListener` hat kein Profil und braucht immer einen `EdaConsentService` + `EdaTopologyService`.
---
### Schritt 2: EDA E-Mail-Konfiguration
**application-prod.yml (ergänzen):**
```yaml
eda:
email:
gateway-address: ${EDA_GATEWAY_EMAIL:eda-gateway@example.com}
poll-interval-ms: ${EDA_POLL_INTERVAL_MS:900000}
imap:
host: ${EDA_IMAP_HOST:}
port: ${EDA_IMAP_PORT:993}
username: ${EDA_IMAP_USERNAME:}
password: ${EDA_IMAP_PASSWORD:}
folder: INBOX
protocol: imaps
smtp:
host: ${EDA_SMTP_HOST:}
port: ${EDA_SMTP_PORT:587}
username: ${EDA_SMTP_USERNAME:}
password: ${EDA_SMTP_PASSWORD:}
```
**application-dev.yml (ergänzen):**
```yaml
eda:
email:
gateway-address: test-eda@example.com
poll-interval-ms: 900000
```
---
### Schritt 3: E-Mail-basierte Consent-Implementierung
#### EDA-E-Mail-Format (Hinweis)
Das EDA-Netzwerk (Elektrizitäts-Daten-Austausch) in Österreich nutzt für E-Mail-Kommunikation typischerweise:
- **Betreff:** Strukturiert mit AT-Nummer + Nachrichtentyp (z.B. `CMRequest|AT...` für Consent)
- **Body:** XML oder strukturiertes Textformat mit AT-Nummer, Entscheidung, Referenz-ID
- **Attachments:** XLSX/CSV für Verbrauchsdaten
- **Zentraler Empfänger:** EDA-Gateway leitet an Netzbetreiber weiter (wie beim Messenger)
Die genaue Spezifikation muss vom EDA-Gateway-Anbieter bestätigt werden. Die Architektur ist flexibel gehalten.
#### Neue Dateien
**EmailEdaConsentService.java** - Prod Consent via SMTP:
- `@Profile("prod")`
- Implementiert `EdaConsentService`
- `sendConsentRequest()`:
1. Baut Consent-E-Mail auf (AT-Nummer, Typ, Portal-Referenz-ID)
2. Versendet an `eda.email.gateway-address` via SMTP
3. Speichert Request-Metadaten für Zuordnung der Antwort
4. Logging
**EdaMailSender.java** - E-Mail-Versand:
- Hilfsklasse zum Aufbau und Versand von EDA-E-Mails
- Consent-Request-E-Mail Vorlage (XML-Format)
- `@Async` für nicht-blockierenden Versand
**EdaMailParser.java** - E-Mail-Parsing:
- Hilfsklasse zum Parsen von EDA-E-Mails
- Erkennt E-Mail-Typ (Consent-Antwort vs. Verbrauchsdaten)
- Extrahiert AT-Nummer, Entscheidung, Referenz-ID
- Flexibel: E-Mail-Format-Konfiguration via Properties
**EdaMailPoller.java** - IMAP-Polling:
- `@Profile("prod")`
- `@Scheduled(fixedDelayString = "${eda.email.poll-interval-ms:900000}")` - alle 15 Minuten
- Verbindet sich via IMAP zum EDA-Postfach
- Parst eingehende E-Mails:
- **Consent-Antwort:** Erkennt AT-Nummer + Entscheidung (approved/rejected)
- **Verbrauchsdaten-Attachment:** Erkennt XLSX/CSV
- Für Consent-Antworten:
- Veröffentlicht `MakoConsentApprovedEvent` / `MakoConsentRejectedEvent` / `MakoConsentTechnicalFailedEvent`
- Für Verbrauchsdaten:
- Nutzt bestehenden `XlsxMeteringDataParser` für XLSX
- Speichert via `MeteringDataService`
- Erstellt `MeteringDataUpload`-Audit-Eintrag
---
### Schritt 4: Verbrauchsdaten-Ingestion via E-Mail
**Integration in EdaMailPoller:**
Der Poller erkennt eingehende E-Mails mit Verbrauchsdaten-Attachments:
1. **Mail-Scan:** Durchsucht Postfach nach neuen E-Mails mit Attachments
2. **Attachment-Erkennung:** XLSX, CSV (je nach EDA-Standard)
3. **Parsing:** Nutzt `XlsxMeteringDataParser` (bestehend)
4. **Speicherung:** Ruft `MeteringDataService.uploadMeteringData()` auf
5. **Audit:** Erstellt `MeteringDataUpload` mit `DataSource.EMAIL_AUTO`
**DataSource erweitern:**
- `DataSource.java` - `EMAIL_AUTO` hinzugefügt
---
### Schritt 5: Tests
**Neue/angepasste Dateien:**
- `EmailEdaConsentServiceTest.java` - Mock SMTP, verify E-Mail-Inhalt
- `EdaMailParserTest.java` - Test verschiedener E-Mail-Formate
- `EdaMailPollerTest.java` - Unit-Test für Polling-Logik
- `SimulatorProfileTest.java` - Verifiziere @Profile("!prod")
---
## Datei-Übersicht
| Aktion | Datei |
|--------|-------|
| Ändern | `SimulatedEdaConsentService.java` - @Profile("!prod") |
| Ändern | `SimulatedEdaTopologyService.java` - @Profile("!prod") |
| Ändern | `application-prod.yml` - EDA E-Mail-Konfiguration |
| Ändern | `application-dev.yml` - EDA Dev-Konfiguration |
| Ändern | `DataSource.java` - EMAIL_AUTO hinzufügen |
| Neu | `EmailEdaConsentService.java` - Prod Consent via SMTP |
| Neu | `EdaMailSender.java` - E-Mail-Versand |
| Neu | `EdaMailParser.java` - E-Mail-Parsing |
| Neu | `EdaMailPoller.java` - IMAP-Polling (15 Min) für Antworten + Verbrauchsdaten |
| Neu | Tests |
---
## Verifizierung
1. **Dev-Modus:** `@Profile("!prod")` aktiv → Simulator wird geladen, kein EDA-Mail-Poller
2. **Prod-Modus:** `@Profile("prod")` aktiv → `EmailEdaConsentService` + `EdaMailPoller` werden geladen
3. **Unit-Tests:** `mvn test -pl eeg_backend` - alle Tests grün (232 Tests)
4. **Integrationstest:**
- Dev: Konsistenter Flow mit Simulator (AT-Nummer endet mit REJECT/ERROR)
- Prod: Echte SMTP/IMAP-Verbindung testen (kann mit Test-Server)
5. **Manuell prüfen:**
- Consent-Flow: User registrieren → Admin genehmigen → MeteringPoint wechselt zu WAITING_FOR_CONSENT
- Simulator: AT-Nummer "AT0010001234567890123456789012345" → Consent Approved nach Delay
- E-Mail: Echte E-Mail wird an Gateway gesendet
---
## Offene Fragen
1. **EDA-Spezifikation:** Die genaue E-Mail-Format-Spezifikation muss vom EDA-Gateway-Anbieter bezogen werden. Die Architektur ist flexibel gehalten (Konfiguration via Properties).
2. **Polling-Intervall:** **Entschieden: alle 15 Minuten** (Standardwert in Konfiguration, konfigurierbar).
3. **Fehlerbehandlung:** Was passiert bei nicht zuordenbaren E-Mails? (Log + Flag) - Muss noch definiert werden.
4. **Verbrauchsdaten-Format:** XLSX oder CSV? (XLSX-Parsing existiert bereits) - Muss mit EDA-Gateway-Anbieter abgestimmt werden.
---
## Implementierungsstand
**Status: ABGESCHLOSSEN**
Alle 6 Schritte wurden implementiert und alle 232 Tests bestehen.

View file

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

View file

@ -0,0 +1,35 @@
package at.mueller.eeg.backend.mako.api;
import at.mueller.eeg.backend.mako.api.dto.EdaSettingsDto;
import at.mueller.eeg.backend.mako.domain.EdaSettings;
import at.mueller.eeg.backend.mako.service.EdaSettingsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(value = "/api/admin/eda-settings", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin EDA Settings", description = "Konfiguration des EDA-Kommunikationswegs")
@RequiredArgsConstructor
public class AdminEdaSettingsController {
private final EdaSettingsService edaSettingsService;
@GetMapping
@Operation(summary = "Aktuelle EDA-Einstellungen abrufen")
public EdaSettingsDto getSettings() {
EdaSettings settings = edaSettingsService.getSettings();
return new EdaSettingsDto(settings.getCommunicationMethod());
}
@PutMapping
@Operation(summary = "EDA-Kommunikationsweg aktualisieren")
public EdaSettingsDto updateSettings(@RequestBody EdaSettingsDto dto) {
EdaSettings updated = edaSettingsService.updateCommunicationMethod(dto.communicationMethod());
return new EdaSettingsDto(updated.getCommunicationMethod());
}
}

View file

@ -0,0 +1,6 @@
package at.mueller.eeg.backend.mako.api.dto;
import at.mueller.eeg.backend.mako.domain.EdaCommunicationMethod;
public record EdaSettingsDto(EdaCommunicationMethod communicationMethod) {
}

View file

@ -0,0 +1,6 @@
package at.mueller.eeg.backend.mako.domain;
public enum EdaCommunicationMethod {
EMAIL,
MESSENGER
}

View file

@ -0,0 +1,22 @@
package at.mueller.eeg.backend.mako.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Entity
@Table(name = "eda_settings")
@Getter
@Setter
public class EdaSettings {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private EdaCommunicationMethod communicationMethod = EdaCommunicationMethod.EMAIL;
}

View file

@ -0,0 +1,86 @@
package at.mueller.eeg.backend.mako.email;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import jakarta.mail.internet.MimeMessage;
import java.io.IOException;
import java.util.Optional;
import java.util.Properties;
@Slf4j
@Profile("prod")
@Component
public class EdaMailParser {
public record ConsentResponse(String atNumber, ConsentDecision decision, String reason) {
public enum ConsentDecision {
APPROVED, REJECTED, TECHNICAL_FAILURE
}
}
public record MeteringDataMail(String atNumber, byte[] attachment, String fileName) {
}
public Optional<ConsentResponse> parseConsentResponse(MimeMessage message) {
try {
String subject = message.getSubject();
if (subject == null || !subject.startsWith("CMResponse|")) {
return Optional.empty();
}
String atNumber = subject.substring("CMResponse|".length()).trim();
String body = extractTextContent(message);
ConsentResponse.ConsentDecision decision = extractDecision(body);
String reason = extractReason(body);
return Optional.of(new ConsentResponse(atNumber, decision, reason));
} catch (Exception e) {
log.warn("[EDA-PARSER] Fehler beim Parsen der Consent-Antwort: {}", e.getMessage());
return Optional.empty();
}
}
public boolean isMeteringDataMail(MimeMessage message) {
try {
String subject = message.getSubject();
return subject != null && subject.startsWith("Verbrauchsdaten|");
} catch (Exception e) {
return false;
}
}
private ConsentResponse.ConsentDecision extractDecision(String body) {
String upperBody = body.toUpperCase();
if (upperBody.contains("APPROVED") || upperBody.contains("ZUSTIMMUNG_ERT") || upperBody.contains("ZUSTIMMUNG_ERTEILT")) {
return ConsentResponse.ConsentDecision.APPROVED;
} else if (upperBody.contains("REJECTED") || upperBody.contains("ABGELEHNT")) {
return ConsentResponse.ConsentDecision.REJECTED;
}
return ConsentResponse.ConsentDecision.TECHNICAL_FAILURE;
}
private String extractReason(String body) {
int reasonStart = body.indexOf("<REASON>");
int reasonEnd = body.indexOf("</REASON>");
if (reasonStart >= 0 && reasonEnd > reasonStart) {
return body.substring(reasonStart + 8, reasonEnd).trim();
}
return null;
}
private String extractTextContent(MimeMessage message) {
try {
Object content = message.getContent();
if (content instanceof String text) {
return text;
}
return "";
} catch (IOException | jakarta.mail.MessagingException e) {
return "";
}
}
}

View file

@ -0,0 +1,180 @@
package at.mueller.eeg.backend.mako.email;
import at.mueller.eeg.backend.common.event.MakoConsentApprovedEvent;
import at.mueller.eeg.backend.common.event.MakoConsentRejectedEvent;
import at.mueller.eeg.backend.common.event.MakoConsentTechnicalFailedEvent;
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.DataSource;
import at.mueller.eeg.backend.community.service.MeteringDataService;
import at.mueller.eeg.backend.community.service.XlsxMeteringDataParser;
import jakarta.mail.*;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.List;
import java.util.Properties;
@Slf4j
@Profile("prod")
@Component
@RequiredArgsConstructor
public class EdaMailPoller {
private final ApplicationEventPublisher eventPublisher;
private final EdaMailParser edaMailParser;
private final MeteringDataService meteringDataService;
private final XlsxMeteringDataParser xlsxMeteringDataParser;
@Value("${eda.email.imap.host}")
private String imapHost;
@Value("${eda.email.imap.port}")
private int imapPort;
@Value("${eda.email.imap.username}")
private String imapUsername;
@Value("${eda.email.imap.password}")
private String imapPassword;
@Value("${eda.email.imap.folder:INBOX}")
private String folderName;
@Value("${eda.email.imap.protocol:imaps}")
private String protocol;
@Scheduled(fixedDelayString = "${eda.email.poll-interval-ms:900000}")
public void pollMailbox() {
if (imapHost == null || imapHost.isBlank()) {
log.debug("[EDA-POLLER] Kein IMAP-Host konfiguriert, Poller uebersprungen.");
return;
}
log.info("[EDA-POLLER] Starte Mailbox-Polling...");
try {
Properties props = new Properties();
props.put("mail.store.protocol", protocol);
props.put("mail." + protocol + ".host", imapHost);
props.put("mail." + protocol + ".port", imapPort);
props.put("mail." + protocol + ".ssl.enable", "true");
Session session = Session.getInstance(props, null);
Store store = session.getStore(protocol);
store.connect(imapHost, imapPort, imapUsername, imapPassword);
Folder folder = store.getFolder(folderName);
folder.open(Folder.READ_WRITE);
Message[] messages = folder.getMessages();
log.info("[EDA-POLLER] {} neue Nachrichten gefunden.", messages.length);
for (Message message : messages) {
processMessage(message);
message.setFlag(Flags.Flag.SEEN, true);
}
folder.close(true);
store.close();
} catch (Exception e) {
log.error("[EDA-POLLER] Fehler beim Polling: {}", e.getMessage());
}
}
private void processMessage(Message message) {
try {
if (message instanceof MimeMessage mimeMessage) {
if (edaMailParser.isMeteringDataMail(mimeMessage)) {
processMeteringDataMail(mimeMessage);
} else {
processPotentialConsentResponse(mimeMessage);
}
}
} catch (Exception e) {
log.warn("[EDA-POLLER] Fehler beim Verarbeiten einer Nachricht: {}", e.getMessage());
}
}
private void processPotentialConsentResponse(MimeMessage message) {
edaMailParser.parseConsentResponse(message).ifPresent(response -> {
log.info("[EDA-POLLER] Consent-Antwort empfangen: AT-Nummer={}, Entscheidung={}",
response.atNumber(), response.decision());
switch (response.decision()) {
case APPROVED -> eventPublisher.publishEvent(new MakoConsentApprovedEvent(response.atNumber()));
case REJECTED -> eventPublisher.publishEvent(new MakoConsentRejectedEvent(
response.atNumber(), response.reason()));
case TECHNICAL_FAILURE -> eventPublisher.publishEvent(new MakoConsentTechnicalFailedEvent(
response.atNumber(), response.reason()));
}
});
}
private void processMeteringDataMail(MimeMessage message) {
try {
String subject = message.getSubject();
String atNumber = subject != null && subject.startsWith("Verbrauchsdaten|")
? subject.substring("Verbrauchsdaten|".length()).trim()
: "UNKNOWN";
log.info("[EDA-POLLER] Verarbeite Verbrauchsdaten-Email fuer AT-Nummer: {}", atNumber);
if (message.getContent() instanceof Multipart multipart) {
for (int i = 0; i < multipart.getCount(); i++) {
BodyPart bodyPart = multipart.getBodyPart(i);
if (Part.ATTACHMENT.equalsIgnoreCase(bodyPart.getDisposition())) {
String fileName = bodyPart.getFileName();
if (fileName != null && (fileName.endsWith(".xlsx") || fileName.endsWith(".xls"))) {
processXlsxAttachment(bodyPart.getInputStream(), fileName, atNumber);
}
}
}
}
} catch (Exception e) {
log.error("[EDA-POLLER] Fehler bei der Verarbeitung der Verbrauchsdaten-Email: {}", e.getMessage());
}
}
private void processXlsxAttachment(InputStream inputStream, String fileName, String atNumber) {
try {
log.info("[EDA-POLLER] Verarbeite XLSX-Attachment: {}", fileName);
List<MeteringDataRecordDto> records = xlsxMeteringDataParser.parse(inputStream);
if (records.isEmpty()) {
log.warn("[EDA-POLLER] Keine gueltigen Datensaetze in der Datei: {}", fileName);
return;
}
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_AUTO,
fileName,
records
);
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, null);
if (response.validationErrors() != null && !response.validationErrors().isEmpty()) {
log.warn("[EDA-POLLER] Validierungsfehler beim Import: {}", response.validationErrors());
} else {
log.info("[EDA-POLLER] Verbrauchsdaten erfolgreich importiert: {} Datensaetze aus Datei {}",
response.recordCount(), fileName);
}
} catch (Exception e) {
log.error("[EDA-POLLER] Fehler beim Import der XLSX-Datei {}: {}", fileName, e.getMessage());
}
}
}

View file

@ -0,0 +1,60 @@
package at.mueller.eeg.backend.mako.email;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
@Slf4j
@Profile("prod")
@Component
@RequiredArgsConstructor
public class EdaMailSender {
private final JavaMailSender javaMailSender;
@Value("${eda.email.gateway-address}")
private String gatewayAddress;
@Async
public void sendConsentRequest(String atNumber, String pointType) {
try {
MimeMessage message = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(gatewayAddress);
helper.setTo(gatewayAddress);
helper.setSubject("CMRequest|" + atNumber);
String body = buildConsentRequestBody(atNumber, pointType);
helper.setText(body, false);
javaMailSender.send(message);
log.info("[EDA-MAIL] Consent-Request gesendet an {}: AT-Nummer={}, Typ={}",
gatewayAddress, atNumber, pointType);
} catch (MessagingException e) {
log.error("[EDA-MAIL] Fehler beim Senden des Consent-Requests fuer AT-Nummer {}: {}",
atNumber, e.getMessage());
throw new RuntimeException("EDA-Email-Versand fehlgeschlagen", e);
}
}
private String buildConsentRequestBody(String atNumber, String pointType) {
return """
<EDA_REQUEST>
<TYPE>CONSENT</TYPE>
<AT_NUMBER>%s</AT_NUMBER>
<POINT_TYPE>%s</POINT_TYPE>
<TIMESTAMP>%s</TIMESTAMP>
</EDA_REQUEST>
""".formatted(atNumber, pointType, java.time.Instant.now());
}
}

View file

@ -2,7 +2,7 @@ package at.mueller.eeg.backend.mako.event.listener;
import at.mueller.eeg.backend.common.event.*;
import at.mueller.eeg.backend.mako.api.dto.EdaTopologyResponse;
import at.mueller.eeg.backend.mako.service.EdaConsentService;
import at.mueller.eeg.backend.mako.service.EdaCommunicationRouter;
import at.mueller.eeg.backend.mako.service.EdaTopologyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -18,7 +18,7 @@ import org.springframework.stereotype.Component;
public class EdaCommunicationEventListener {
private final EdaTopologyService edaTopologyService;
private final EdaConsentService edaConsentService;
private final EdaCommunicationRouter edaCommunicationRouter;
private final ApplicationEventPublisher eventPublisher;
@EventListener
@ -59,7 +59,7 @@ public class EdaCommunicationEventListener {
event.atNumber(), event.pointType());
try {
edaConsentService.sendConsentRequest(event.atNumber(), event.pointType());
edaCommunicationRouter.resolve().sendConsentRequest(event.atNumber(), event.pointType());
log.info("MaKo-Domain: Consent-Request erfolgreich an EDA übermittelt für {}", event.atNumber());

View file

@ -0,0 +1,11 @@
package at.mueller.eeg.backend.mako.repository;
import at.mueller.eeg.backend.mako.domain.EdaSettings;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface EdaSettingsRepository extends JpaRepository<EdaSettings, UUID> {
Optional<EdaSettings> findFirstByOrderByIdAsc();
}

View file

@ -0,0 +1,52 @@
package at.mueller.eeg.backend.mako.service;
import at.mueller.eeg.backend.mako.domain.EdaCommunicationMethod;
import at.mueller.eeg.backend.mako.domain.EdaSettings;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import java.util.Arrays;
@Slf4j
@Service
public class EdaCommunicationRouter {
private final EdaSettingsService settingsService;
private final Environment environment;
private final EdaConsentService simulatedService;
private final ObjectProvider<EdaConsentService> emailServiceProvider;
private final ObjectProvider<EdaConsentService> messengerServiceProvider;
public EdaCommunicationRouter(
EdaSettingsService settingsService,
Environment environment,
@Qualifier("simulatedEdaConsentService") EdaConsentService simulatedService,
@Qualifier("emailEdaConsentService") ObjectProvider<EdaConsentService> emailServiceProvider,
@Qualifier("messengerEdaConsentService") ObjectProvider<EdaConsentService> messengerServiceProvider) {
this.settingsService = settingsService;
this.environment = environment;
this.simulatedService = simulatedService;
this.emailServiceProvider = emailServiceProvider;
this.messengerServiceProvider = messengerServiceProvider;
}
public EdaConsentService resolve() {
if (Arrays.asList(environment.getActiveProfiles()).contains("dev")) {
log.debug("[EDA-ROUTER] Dev-Modus: Simulation wird verwendet.");
return simulatedService;
}
EdaSettings settings = settingsService.getSettings();
EdaCommunicationMethod method = settings.getCommunicationMethod();
log.info("[EDA-ROUTER] Kommunikationsweg = {}", method);
return switch (method) {
case EMAIL -> emailServiceProvider.getObject();
case MESSENGER -> messengerServiceProvider.getObject();
};
}
}

View file

@ -0,0 +1,38 @@
package at.mueller.eeg.backend.mako.service;
import at.mueller.eeg.backend.mako.domain.EdaCommunicationMethod;
import at.mueller.eeg.backend.mako.domain.EdaSettings;
import at.mueller.eeg.backend.mako.repository.EdaSettingsRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class EdaSettingsService {
private final EdaSettingsRepository repository;
@Transactional(readOnly = true)
public EdaSettings getSettings() {
return repository.findFirstByOrderByIdAsc()
.orElseGet(this::createDefault);
}
@Transactional
public EdaSettings updateCommunicationMethod(EdaCommunicationMethod method) {
EdaSettings settings = getSettings();
settings.setCommunicationMethod(method);
EdaSettings saved = repository.save(settings);
log.info("EDA-Kommunikationsweg geaendert auf: {}", method);
return saved;
}
private EdaSettings createDefault() {
EdaSettings defaultSettings = new EdaSettings();
defaultSettings.setCommunicationMethod(EdaCommunicationMethod.EMAIL);
return repository.save(defaultSettings);
}
}

View file

@ -0,0 +1,31 @@
package at.mueller.eeg.backend.mako.service.impl;
import at.mueller.eeg.backend.mako.email.EdaMailSender;
import at.mueller.eeg.backend.mako.service.EdaConsentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@Slf4j
@Service("emailEdaConsentService")
public class EmailEdaConsentService implements EdaConsentService {
private final EdaMailSender edaMailSender;
public EmailEdaConsentService(@Lazy EdaMailSender edaMailSender) {
this.edaMailSender = edaMailSender;
}
@Override
public void sendConsentRequest(String atNumber, String pointType) {
log.info("[EDA-EMAIL] Sende Consent-Request fuer AT-Nummer: {} (Typ: {})", atNumber, pointType);
if (atNumber == null || atNumber.isBlank()) {
throw new IllegalArgumentException("AT-Nummer darf nicht leer sein fuer EDA-Request.");
}
edaMailSender.sendConsentRequest(atNumber, pointType);
log.info("[EDA-EMAIL] Consent-Request erfolgreich an EDA-Gateway gesendet fuer {}", atNumber);
}
}

View file

@ -0,0 +1,16 @@
package at.mueller.eeg.backend.mako.service.impl;
import at.mueller.eeg.backend.mako.service.EdaConsentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service("messengerEdaConsentService")
public class MessengerEdaConsentService implements EdaConsentService {
@Override
public void sendConsentRequest(String atNumber, String pointType) {
log.warn("[EDA-MESSENGER] Messenger-Interface noch nicht implementiert. AT-Nummer: {}, Typ: {}", atNumber, pointType);
throw new UnsupportedOperationException("Messenger-Interface fuer EDA-Kommunikation noch nicht implementiert.");
}
}

View file

@ -16,7 +16,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@Service("simulatedEdaConsentService")
@RequiredArgsConstructor
public class SimulatedEdaConsentService implements EdaConsentService {

View file

@ -4,11 +4,13 @@ import at.mueller.eeg.backend.mako.api.dto.EdaTopologyResponse;
import at.mueller.eeg.backend.mako.service.EdaTopologyService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Slf4j
@Profile("!prod")
@Service
public class SimulatedEdaTopologyService implements EdaTopologyService {

View file

@ -17,3 +17,6 @@ eda:
simulation:
consent-delay-ms: 3000
topology-delay-ms: 1000
email:
gateway-address: test-eda@example.com
poll-interval-ms: 900000

View file

@ -52,3 +52,20 @@ logging:
at.mueller.eeg: INFO
org.springframework.security: WARN
org.hibernate.SQL: WARN
eda:
email:
gateway-address: ${EDA_GATEWAY_EMAIL:eda-gateway@example.com}
poll-interval-ms: ${EDA_POLL_INTERVAL_MS:900000}
imap:
host: ${EDA_IMAP_HOST:}
port: ${EDA_IMAP_PORT:993}
username: ${EDA_IMAP_USERNAME:}
password: ${EDA_IMAP_PASSWORD:}
folder: INBOX
protocol: imaps
smtp:
host: ${EDA_SMTP_HOST:}
port: ${EDA_SMTP_PORT:587}
username: ${EDA_SMTP_USERNAME:}
password: ${EDA_SMTP_PASSWORD:}

View file

@ -0,0 +1,137 @@
package at.mueller.eeg.backend.dashboard.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.repository.MeteringDataRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import at.mueller.eeg.backend.community.service.MeteringDataService;
import at.mueller.eeg.backend.dashboard.api.dto.DashboardStatsDto;
import at.mueller.eeg.backend.iam.domain.ParticipantType;
import at.mueller.eeg.backend.iam.domain.RegistrationStatus;
import at.mueller.eeg.backend.iam.domain.User;
import at.mueller.eeg.backend.iam.domain.UserRole;
import at.mueller.eeg.backend.iam.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class DashboardServiceIntegrationTest {
@Autowired
private DashboardService dashboardService;
@Autowired
private MeteringDataService meteringDataService;
@Autowired
private MeteringPointRepository meteringPointRepository;
@Autowired
private MeteringDataRepository meteringDataRepository;
@Autowired
private UserRepository userRepository;
private User testUser;
private MeteringPoint testMeteringPoint;
@BeforeEach
void setUp() {
testUser = new User();
testUser.setEmail("test-dashboard-" + UUID.randomUUID() + "@example.com");
testUser.setPasswordHash("encoded");
testUser.setFirstName("Test");
testUser.setLastName("Dashboard");
testUser.setRole(UserRole.MEMBER);
testUser.setStatus(RegistrationStatus.APPROVED);
testUser.setParticipantType(ParticipantType.PRIVATE);
testUser.setEnabled(true);
testUser = userRepository.save(testUser);
testMeteringPoint = new MeteringPoint();
testMeteringPoint.setUserId(testUser.getId());
testMeteringPoint.setAtNumber("AT0010000000000000000000001234567");
testMeteringPoint.setType(PointType.CONSUMER);
testMeteringPoint.setMakoState(MakoState.ACTIVE);
testMeteringPoint = meteringPointRepository.save(testMeteringPoint);
}
@Test
void getMeteringDataOverview_afterUpload_returnsNonEmptyTimeSeries() {
// Upload hourly data for 3 days
List<MeteringDataRecordDto> records = new ArrayList<>();
Instant base = Instant.parse("2026-06-01T00:00:00Z");
for (int i = 0; i < 72; i++) { // 3 days * 24 hours
Instant start = base.plusSeconds(i * 3600L);
Instant end = start.plusSeconds(3600);
records.add(new MeteringDataRecordDto(
testMeteringPoint.getAtNumber(), MeteringDataType.CONSUMPTION,
start, end, 0.5 + (i % 10) * 0.1, null));
}
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX, "test.xlsx", records);
MeteringDataUploadResponse uploadResponse = meteringDataService.uploadMeteringData(request, testUser.getId());
assertEquals(UploadStatus.COMPLETED, uploadResponse.status());
// Now query via dashboard service
Instant from = Instant.parse("2026-06-01T00:00:00Z");
Instant to = Instant.parse("2026-06-04T00:00:00Z");
DashboardStatsDto.MeteringDataOverview overview = dashboardService.getMeteringDataOverview(testUser.getId(), from, to);
assertNotNull(overview);
assertFalse(overview.meteringPoints().isEmpty(), "Should have metering points");
assertFalse(overview.timeSeries().isEmpty(), "Should have time series data");
// Data spans 3 full days, but timezone grouping may add 1 day
assertTrue(overview.timeSeries().size() >= 3, "Should have at least 3 days of data");
assertEquals(1, overview.meteringPoints().size());
assertTrue(overview.meteringPoints().get(0).totalKwh() > 0, "Total kWh should be > 0");
}
@Test
void getMeteringDataOverview_fullYearData_returnsTimeSeries() {
// Upload full year hourly data
List<MeteringDataRecordDto> records = new ArrayList<>();
Instant base = Instant.parse("2026-01-01T00:00:00Z");
for (int i = 0; i < 8760; i++) {
Instant start = base.plusSeconds(i * 3600L);
Instant end = start.plusSeconds(3600);
records.add(new MeteringDataRecordDto(
testMeteringPoint.getAtNumber(), MeteringDataType.CONSUMPTION,
start, end, 0.5 + (i % 10) * 0.1, null));
}
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX, "fullyear.xlsx", records);
MeteringDataUploadResponse uploadResponse = meteringDataService.uploadMeteringData(request, testUser.getId());
assertEquals(UploadStatus.COMPLETED, uploadResponse.status());
// Query last 30 days
Instant from = Instant.parse("2026-06-24T00:00:00Z");
Instant to = Instant.parse("2026-07-24T00:00:00Z");
DashboardStatsDto.MeteringDataOverview overview = dashboardService.getMeteringDataOverview(testUser.getId(), from, to);
assertNotNull(overview);
assertFalse(overview.meteringPoints().isEmpty(), "Should have metering points");
assertFalse(overview.timeSeries().isEmpty(), "Should have time series data for the queried range");
assertTrue(overview.timeSeries().size() >= 29, "Should have ~30 days of data");
}
}

View file

@ -1,21 +1,29 @@
package at.mueller.eeg.backend.dashboard.service;
import at.mueller.eeg.backend.community.domain.MembershipStatus;
import at.mueller.eeg.backend.community.domain.*;
import at.mueller.eeg.backend.community.domain.state.MakoState;
import at.mueller.eeg.backend.community.repository.EnergyCommunityRepository;
import at.mueller.eeg.backend.community.repository.MembershipRepository;
import at.mueller.eeg.backend.community.repository.MeteringDataRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import at.mueller.eeg.backend.community.repository.MembershipRepository;
import at.mueller.eeg.backend.dashboard.api.dto.DashboardStatsDto;
import at.mueller.eeg.backend.iam.domain.RegistrationStatus;
import at.mueller.eeg.backend.iam.repository.UserRepository;
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 java.util.UUID;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@ -23,45 +31,131 @@ class DashboardServiceTest {
@Mock
private EnergyCommunityRepository energyCommunityRepository;
@Mock
private UserRepository userRepository;
@Mock
private MembershipRepository membershipRepository;
@Mock
private MeteringPointRepository meteringPointRepository;
@Mock
private MeteringDataRepository meteringDataRepository;
@InjectMocks
private DashboardService dashboardService;
@Test
void getAdminStats_returnsCorrectCounts() {
when(energyCommunityRepository.count()).thenReturn(5L);
when(userRepository.countByStatusAndEmailVerified(RegistrationStatus.PENDING, true)).thenReturn(3L);
when(membershipRepository.countByStatus(MembershipStatus.PENDING)).thenReturn(2L);
when(membershipRepository.countByStatus(MembershipStatus.ACTIVE)).thenReturn(10L);
private UUID userId;
private UUID meteringPointId;
private MeteringPoint meteringPoint;
DashboardStatsDto.AdminStats stats = dashboardService.getAdminStats();
@BeforeEach
void setUp() {
userId = UUID.randomUUID();
meteringPointId = UUID.randomUUID();
assertEquals(5, stats.totalCommunities());
assertEquals(3, stats.pendingUsers());
assertEquals(2, stats.pendingMemberships());
assertEquals(10, stats.totalActiveMemberships());
meteringPoint = new MeteringPoint();
meteringPoint.setId(meteringPointId);
meteringPoint.setUserId(userId);
meteringPoint.setAtNumber("AT0010000000000000000000001234567");
meteringPoint.setType(PointType.CONSUMER);
meteringPoint.setMakoState(MakoState.ACTIVE);
}
@Test
void getUserStats_returnsCorrectCounts() {
UUID userId = UUID.randomUUID();
when(meteringPointRepository.countByUserId(userId)).thenReturn(2L);
when(membershipRepository.countByUserIdAndStatus(userId, MembershipStatus.ACTIVE)).thenReturn(1L);
when(membershipRepository.countByUserIdAndStatus(userId, MembershipStatus.PENDING)).thenReturn(1L);
void getMeteringDataOverview_withData_returnsTimeSeries() {
Instant from = Instant.parse("2026-01-01T00:00:00Z");
Instant to = Instant.parse("2026-01-03T00:00:00Z");
DashboardStatsDto.UserStats stats = dashboardService.getUserStats(userId);
when(meteringPointRepository.findAllByUserId(userId)).thenReturn(List.of(meteringPoint));
assertEquals(2, stats.totalMeteringPoints());
assertEquals(1, stats.activeMemberships());
assertEquals(1, stats.pendingMemberships());
ZoneId systemZone = ZoneId.systemDefault();
List<MeteringData> meteringData = List.of(
createMeteringData(meteringPointId, Instant.parse("2026-01-01T10:00:00Z"), 5.0),
createMeteringData(meteringPointId, Instant.parse("2026-01-02T10:00:00Z"), 3.0)
);
when(meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(meteringPointId, from, to))
.thenReturn(meteringData);
DashboardStatsDto.MeteringDataOverview overview = dashboardService.getMeteringDataOverview(userId, from, to);
assertEquals(1, overview.meteringPoints().size());
assertEquals("AT0010000000000000000000001234567", overview.meteringPoints().get(0).atNumber());
assertEquals(8.0, overview.meteringPoints().get(0).totalKwh(), 0.01);
assertEquals(2, overview.timeSeries().size());
}
@Test
void getMeteringDataOverview_noMeteringPoints_returnsEmptyOverview() {
Instant from = Instant.parse("2026-01-01T00:00:00Z");
Instant to = Instant.parse("2026-01-03T00:00:00Z");
when(meteringPointRepository.findAllByUserId(userId)).thenReturn(Collections.emptyList());
DashboardStatsDto.MeteringDataOverview overview = dashboardService.getMeteringDataOverview(userId, from, to);
assertTrue(overview.meteringPoints().isEmpty());
assertTrue(overview.timeSeries().isEmpty());
}
@Test
void getMeteringDataOverview_noDataInTimeRange_returnsEmptyTimeSeries() {
Instant from = Instant.parse("2026-01-01T00:00:00Z");
Instant to = Instant.parse("2026-01-03T00:00:00Z");
when(meteringPointRepository.findAllByUserId(userId)).thenReturn(List.of(meteringPoint));
when(meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(meteringPointId, from, to))
.thenReturn(Collections.emptyList());
DashboardStatsDto.MeteringDataOverview overview = dashboardService.getMeteringDataOverview(userId, from, to);
assertEquals(1, overview.meteringPoints().size());
assertEquals(0.0, overview.meteringPoints().get(0).totalKwh(), 0.01);
assertTrue(overview.timeSeries().isEmpty());
}
@Test
void getMeteringDataOverview_multipleMeteringPoints_aggregatesCorrectly() {
Instant from = Instant.parse("2026-06-01T00:00:00Z");
Instant to = Instant.parse("2026-06-02T00:00:00Z");
MeteringPoint mp2 = new MeteringPoint();
mp2.setId(UUID.randomUUID());
mp2.setUserId(userId);
mp2.setAtNumber("AT0010000000000000000000001234568");
mp2.setType(PointType.PRODUCER);
mp2.setMakoState(MakoState.ACTIVE);
when(meteringPointRepository.findAllByUserId(userId)).thenReturn(List.of(meteringPoint, mp2));
List<MeteringData> data1 = List.of(createMeteringData(meteringPointId, Instant.parse("2026-06-01T10:00:00Z"), 5.0));
List<MeteringData> data2 = List.of(createMeteringData(mp2.getId(), Instant.parse("2026-06-01T10:00:00Z"), 3.0));
when(meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(meteringPointId, from, to))
.thenReturn(data1);
when(meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(mp2.getId(), from, to))
.thenReturn(data2);
DashboardStatsDto.MeteringDataOverview overview = dashboardService.getMeteringDataOverview(userId, from, to);
assertEquals(2, overview.meteringPoints().size());
assertEquals(1, overview.timeSeries().size());
DashboardStatsDto.TimeSeriesEntry entry = overview.timeSeries().get(0);
assertEquals(5.0, entry.values().get("AT0010000000000000000000001234567"), 0.01);
assertEquals(3.0, entry.values().get("AT0010000000000000000000001234568"), 0.01);
}
private MeteringData createMeteringData(UUID meteringPointId, Instant intervalStart, double kwh) {
MeteringPoint mp = new MeteringPoint();
mp.setId(meteringPointId);
MeteringData data = new MeteringData();
data.setMeteringPoint(mp);
data.setDataType(MeteringDataType.CONSUMPTION);
data.setIntervalStart(intervalStart);
data.setIntervalEnd(intervalStart.plusSeconds(3600));
data.setKwh(kwh);
data.setSource(DataSource.EMAIL_XLSX);
data.setUploadId(UUID.randomUUID());
return data;
}
}

View file

@ -0,0 +1,72 @@
package at.mueller.eeg.backend.mako.email;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class EdaMailParserTest {
private EdaMailParser edaMailParser;
@BeforeEach
void setUp() {
edaMailParser = new EdaMailParser();
}
@Test
void isMeteringDataMail_true() throws Exception {
MimeMessage message = new MimeMessage((jakarta.mail.Session) null);
message.setSubject("Verbrauchsdaten|AT1234567890123456789012345678901");
assertTrue(edaMailParser.isMeteringDataMail(message));
}
@Test
void isMeteringDataMail_false() throws Exception {
MimeMessage message = new MimeMessage((jakarta.mail.Session) null);
message.setSubject("CMResponse|AT1234567890123456789012345678901");
assertFalse(edaMailParser.isMeteringDataMail(message));
}
@Test
void parseConsentResponse_approved() throws Exception {
MimeMessage message = new MimeMessage((jakarta.mail.Session) null);
message.setSubject("CMResponse|AT1234567890123456789012345678901");
message.setText("<EDA_RESPONSE><DECISION>APPROVED</DECISION></EDA_RESPONSE>");
Optional<EdaMailParser.ConsentResponse> response = edaMailParser.parseConsentResponse(message);
assertTrue(response.isPresent());
assertEquals("AT1234567890123456789012345678901", response.get().atNumber());
assertEquals(EdaMailParser.ConsentResponse.ConsentDecision.APPROVED, response.get().decision());
}
@Test
void parseConsentResponse_rejected() throws Exception {
MimeMessage message = new MimeMessage((jakarta.mail.Session) null);
message.setSubject("CMResponse|AT1234567890123456789012345678901");
message.setText("<EDA_RESPONSE><DECISION>REJECTED</DECISION><REASON>Kunde abgelehnt</REASON></EDA_RESPONSE>");
Optional<EdaMailParser.ConsentResponse> response = edaMailParser.parseConsentResponse(message);
assertTrue(response.isPresent());
assertEquals(EdaMailParser.ConsentResponse.ConsentDecision.REJECTED, response.get().decision());
assertEquals("Kunde abgelehnt", response.get().reason());
}
@Test
void parseConsentResponse_notAConsentResponse() throws Exception {
MimeMessage message = new MimeMessage((jakarta.mail.Session) null);
message.setSubject("Some Other Subject");
message.setText("Random content");
Optional<EdaMailParser.ConsentResponse> response = edaMailParser.parseConsentResponse(message);
assertFalse(response.isPresent());
}
}

View file

@ -0,0 +1,47 @@
package at.mueller.eeg.backend.mako.email;
import at.mueller.eeg.backend.common.event.MakoConsentApprovedEvent;
import at.mueller.eeg.backend.community.service.MeteringDataService;
import at.mueller.eeg.backend.community.service.XlsxMeteringDataParser;
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.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class EdaMailPollerTest {
@Mock
private EdaMailParser edaMailParser;
@Mock
private MeteringDataService meteringDataService;
@Mock
private XlsxMeteringDataParser xlsxMeteringDataParser;
@InjectMocks
private EdaMailPoller edaMailPoller;
@Test
void pollMailbox_skipsWhenNoImapHostConfigured() {
ReflectionTestUtils.setField(edaMailPoller, "imapHost", "");
edaMailPoller.pollMailbox();
// Should not throw any exception
}
@Test
void pollMailbox_skipsWhenImapHostIsNull() {
ReflectionTestUtils.setField(edaMailPoller, "imapHost", null);
edaMailPoller.pollMailbox();
// Should not throw any exception
}
}

View file

@ -0,0 +1,43 @@
package at.mueller.eeg.backend.mako.service.impl;
import at.mueller.eeg.backend.mako.email.EdaMailSender;
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 static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class EmailEdaConsentServiceTest {
@Mock
private EdaMailSender edaMailSender;
@InjectMocks
private EmailEdaConsentService emailEdaConsentService;
@Test
void sendConsentRequest_sendsEmailSuccessfully() {
String atNumber = "AT1234567890123456789012345678901";
String pointType = "CONSUMER";
emailEdaConsentService.sendConsentRequest(atNumber, pointType);
verify(edaMailSender).sendConsentRequest(atNumber, pointType);
}
@Test
void sendConsentRequest_throwsExceptionForBlankAtNumber() {
assertThrows(IllegalArgumentException.class,
() -> emailEdaConsentService.sendConsentRequest("", "CONSUMER"));
}
@Test
void sendConsentRequest_throwsExceptionForNullAtNumber() {
assertThrows(IllegalArgumentException.class,
() -> emailEdaConsentService.sendConsentRequest(null, "CONSUMER"));
}
}

View file

@ -0,0 +1,17 @@
package at.mueller.eeg.backend.mako.service.impl;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Profile;
import static org.junit.jupiter.api.Assertions.*;
class SimulatorProfileTest {
@Test
void simulatedEdaTopologyService_hasNotProdProfile() {
Profile profile = SimulatedEdaTopologyService.class.getAnnotation(Profile.class);
assertNotNull(profile, "SimulatedEdaTopologyService should have @Profile annotation");
assertArrayEquals(new String[]{"!prod"}, profile.value(),
"SimulatedEdaTopologyService should have @Profile(\"!prod\")");
}
}

View file

@ -10,6 +10,8 @@ tags:
description: Public Endpoints für Login und Registrierung
- name: User Profile
description: Profilverwaltung und Passwort-Reset
- name: Admin EDA Settings
description: Konfiguration des EDA-Kommunikationswegs
- name: Admin IAM
description: User-Verwaltung und Freigabe-Workflow
paths:
@ -305,6 +307,37 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/CommunityTariffResponse"
/api/admin/eda-settings:
get:
tags:
- Admin EDA Settings
summary: Aktuelle EDA-Einstellungen abrufen
operationId: getSettings
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/EdaSettingsDto"
put:
tags:
- Admin EDA Settings
summary: EDA-Kommunikationsweg aktualisieren
operationId: updateSettings
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/EdaSettingsDto"
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/EdaSettingsDto"
/api/tariffs:
post:
tags:
@ -1235,6 +1268,14 @@ components:
updatedAt:
type: string
format: date-time
EdaSettingsDto:
type: object
properties:
communicationMethod:
type: string
enum:
- EMAIL
- MESSENGER
TariffInviteRequest:
type: object
properties:
@ -1339,6 +1380,7 @@ components:
type: string
enum:
- EMAIL_XLSX
- EMAIL_AUTO
- API
- MANUAL
fileName:
@ -1630,6 +1672,7 @@ components:
type: string
enum:
- EMAIL_XLSX
- EMAIL_AUTO
- API
- MANUAL
MembershipResponse:

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,7 @@
"@angular/forms": "^21.2.0",
"@angular/material": "^21.2.12",
"@angular/platform-browser": "^21.2.0",
"@angular/platform-browser-dynamic": "^20.0.7",
"@angular/platform-browser-dynamic": "^21.2.0",
"@angular/router": "^21.2.0",
"@tailwindcss/postcss": "^4.3.0",
"echarts": "^6.1.0",

View file

@ -6,6 +6,7 @@ import {BASE_PATH} from './api';
import {jwtInterceptor} from './interceptors/jwt.interceptor';
import {errorInterceptor} from './interceptors/error.interceptor';
import {environment} from '../environments/environment';
import {provideEcharts} from 'ngx-echarts';
export const appConfig: ApplicationConfig = {
providers: [
@ -13,6 +14,7 @@ export const appConfig: ApplicationConfig = {
provideRouter(routes),
provideHttpClient(withFetch(),
withInterceptors([jwtInterceptor, errorInterceptor])),
{ provide: BASE_PATH, useValue: environment.apiUrl }
{ provide: BASE_PATH, useValue: environment.apiUrl },
provideEcharts()
]
};

View file

@ -22,6 +22,7 @@ import {ConsumptionDashboardComponent} from './pages/consumption-dashboard/consu
import {UploadMeteringDataComponent} from './pages/upload-metering-data/upload-metering-data';
import {ProducerInvitesComponent} from './pages/producer-invites/producer-invites';
import {ConsumerInvitesComponent} from './pages/consumer-invites/consumer-invites';
import {AdminEdaSettingsComponent} from './pages/admin-eda-settings/admin-eda-settings';
export const routes: Routes = [
{ path: '', component: LandingPage },
@ -76,6 +77,11 @@ export const routes: Routes = [
component: UploadMeteringDataComponent,
canActivate: [roleGuard(['ADMIN'])]
},
{
path: 'admin-eda-settings',
component: AdminEdaSettingsComponent,
canActivate: [roleGuard(['ADMIN'])]
},
{
path: 'my-tariffs',
component: UserTariffComponent,

View file

@ -29,4 +29,5 @@ export const NAV_ITEMS: NavItem[] = [
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN'], section: 'Verwaltung'},
{label: 'Tarife verwalten', path: '/dashboard/tariffs', roles: ['ADMIN'], section: 'Verwaltung'},
{label: 'Messdaten-Upload', path: '/dashboard/upload-metering-data', roles: ['ADMIN'], section: 'Verwaltung'},
{label: 'EDA-Einstellungen', path: '/dashboard/admin-eda-settings', roles: ['ADMIN'], section: 'Verwaltung'},
];

View file

@ -0,0 +1,54 @@
<div class="max-w-2xl mx-auto p-4 md:p-6">
<div class="mb-6">
<h2 class="text-2xl font-bold text-slate-800">EDA-Einstellungen</h2>
<p class="text-sm text-slate-500 mt-1">Konfiguriere den Kommunikationsweg fuer die EDA-Zustimmungsabfrage.</p>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
@if (isLoading()) {
<div class="text-center py-4 text-slate-500">Lade Einstellungen...</div>
} @else {
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-slate-700 mb-3">Kommunikationsweg</label>
<div class="space-y-3">
<label class="flex items-start gap-3 p-4 border border-slate-200 rounded-xl cursor-pointer transition-colors"
[class.border-blue-500]="selectedMethod() === 'EMAIL'"
[class.bg-blue-50]="selectedMethod() === 'EMAIL'"
[class.hover:border-slate-300]="selectedMethod() !== 'EMAIL'">
<input type="radio" name="edaMethod" value="EMAIL"
[checked]="selectedMethod() === 'EMAIL'"
(change)="selectedMethod.set('EMAIL')"
class="mt-0.5 text-blue-600 focus:ring-blue-500">
<div>
<div class="font-medium text-slate-800">E-Mail</div>
<div class="text-sm text-slate-500 mt-0.5">Consent-Requests werden per E-Mail an das EDA-Gateway gesendet.</div>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-slate-200 rounded-xl cursor-pointer transition-colors"
[class.border-blue-500]="selectedMethod() === 'MESSENGER'"
[class.bg-blue-50]="selectedMethod() === 'MESSENGER'"
[class.hover:border-slate-300]="selectedMethod() !== 'MESSENGER'">
<input type="radio" name="edaMethod" value="MESSENGER"
[checked]="selectedMethod() === 'MESSENGER'"
(change)="selectedMethod.set('MESSENGER')"
class="mt-0.5 text-blue-600 focus:ring-blue-500">
<div>
<div class="font-medium text-slate-800">Messenger</div>
<div class="text-sm text-slate-500 mt-0.5">Consent-Requests werden ueber den Messenger gesendet (noch nicht implementiert).</div>
</div>
</label>
</div>
</div>
<div class="flex justify-end">
<button (click)="saveSettings()" [disabled]="isSaving()"
class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-xl font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
{{ isSaving() ? 'Speichern...' : 'Speichern' }}
</button>
</div>
</div>
}
</div>
</div>

View file

@ -0,0 +1,28 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideHttpClient} from '@angular/common/http';
import {provideHttpClientTesting} from '@angular/common/http/testing';
import {AdminEdaSettingsComponent} from './admin-eda-settings';
import {describe, expect, it} from 'vitest';
describe('AdminEdaSettingsComponent', () => {
let component: AdminEdaSettingsComponent;
let fixture: ComponentFixture<AdminEdaSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminEdaSettingsComponent],
providers: [provideHttpClient(), provideHttpClientTesting()]
}).compileComponents();
fixture = TestBed.createComponent(AdminEdaSettingsComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default method EMAIL', () => {
expect(component.selectedMethod()).toBe('EMAIL');
});
});

View file

@ -0,0 +1,58 @@
import {Component, inject, OnInit, signal} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {ToastService} from '../../services/toast';
import {AdminEDASettingsService, EdaSettingsDto} from '../../api';
@Component({
selector: 'app-admin-eda-settings',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-eda-settings.html',
})
export class AdminEdaSettingsComponent implements OnInit {
private edaSettingsService = inject(AdminEDASettingsService);
private toastService = inject(ToastService);
selectedMethod = signal<EdaSettingsDto.CommunicationMethodEnum>('EMAIL');
isLoading = signal(false);
isSaving = signal(false);
ngOnInit() {
this.loadSettings();
}
loadSettings() {
this.isLoading.set(true);
this.edaSettingsService.getSettings().subscribe({
next: (settings) => {
if (settings.communicationMethod) {
this.selectedMethod.set(settings.communicationMethod);
}
this.isLoading.set(false);
},
error: (err) => {
this.toastService.error(err.error?.message || 'Fehler beim Laden der EDA-Einstellungen.');
this.isLoading.set(false);
}
});
}
saveSettings() {
this.isSaving.set(true);
const dto: EdaSettingsDto = {communicationMethod: this.selectedMethod()};
this.edaSettingsService.updateSettings(dto).subscribe({
next: (settings) => {
if (settings.communicationMethod) {
this.selectedMethod.set(settings.communicationMethod);
}
this.toastService.success('EDA-Einstellungen erfolgreich gespeichert.');
this.isSaving.set(false);
},
error: (err) => {
this.toastService.error(err.error?.message || 'Fehler beim Speichern der EDA-Einstellungen.');
this.isSaving.set(false);
}
});
}
}

View file

@ -63,8 +63,8 @@ export class ConsumptionDashboardComponent implements OnInit {
this.isLoading.set(true);
this.error.set(null);
const from = this.dateFrom() + 'T00:00:00';
const to = this.dateTo() + 'T23:59:59';
const from = this.dateFrom() + 'T00:00:00Z';
const to = this.dateTo() + 'T23:59:59Z';
this.consumptionService.getDashboard(mpId, from, to).subscribe({
next: (data) => {

View file

@ -65,7 +65,11 @@ export class DashboardOverview implements OnInit {
this.meteringData.set(data);
this.buildChartOptions(data);
},
error: () => {}
error: (err) => {
console.error('Fehler beim Laden der Messdaten:', err);
this.error.set('Fehler beim Laden der Messdaten.');
this.isLoading.set(false);
}
});
}

View file

@ -195,7 +195,7 @@ export class UserTariffComponent implements OnInit, OnDestroy {
return;
}
this.userTariffService.deleteUserTariff(tariff.id!, userId).subscribe({
this.userTariffService.deleteUserTariff(tariff.id!).subscribe({
next: () => {
this.toastService.success('Tarif erfolgreich gelöscht.');
this.loadUserTariffs(this.selectedCommunityId());