feat(tariff): implement complete tariff module with admin and user tariffs
Backend: - Add CommunityTariff entity (admin-managed max price + surcharge per community) - Add UserTariff entity (user-agreed tariffs between metering points) - Add TariffController (admin endpoints) and UserTariffController (user endpoints) - Add TariffService with full validation (price <= max, ACTIVE state, membership) - Add CommunityTariffChangedEvent and UserTariffChangedEvent - Update NotificationListener to notify members on tariff changes - Add 27 comprehensive tests for TariffService Frontend: - Add AdminTariffComponent (/dashboard/tariffs) for managing community tariffs - Add UserTariffComponent (/dashboard/my-tariffs) for managing user tariffs - Add routing and navigation entries - Regenerate OpenAPI spec and frontend API client Note: Old Tariff.java entity replaced by CommunityTariff + UserTariff
This commit is contained in:
parent
a90b7ecc7e
commit
0dfaa2d507
@ -0,0 +1,8 @@
|
||||
package at.mueller.eeg.backend.common.event;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record CommunityTariffChangedEvent(
|
||||
UUID energyCommunityId,
|
||||
String changeType
|
||||
) {}
|
||||
@ -0,0 +1,10 @@
|
||||
package at.mueller.eeg.backend.common.event;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record UserTariffChangedEvent(
|
||||
UUID energyCommunityId,
|
||||
UUID sourceUserId,
|
||||
UUID targetUserId,
|
||||
String changeType
|
||||
) {}
|
||||
@ -3,6 +3,7 @@ package at.mueller.eeg.backend.common.event.listener;
|
||||
import at.mueller.eeg.backend.common.domain.NotificationType;
|
||||
import at.mueller.eeg.backend.common.service.NotificationService;
|
||||
import at.mueller.eeg.backend.community.domain.MeteringPoint;
|
||||
import at.mueller.eeg.backend.community.repository.MembershipRepository;
|
||||
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
|
||||
import at.mueller.eeg.backend.common.event.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -11,6 +12,7 @@ import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@ -20,6 +22,7 @@ public class NotificationListener {
|
||||
|
||||
private final NotificationService notificationService;
|
||||
private final MeteringPointRepository meteringPointRepository;
|
||||
private final MembershipRepository membershipRepository;
|
||||
|
||||
@EventListener
|
||||
@Transactional
|
||||
@ -98,6 +101,41 @@ public class NotificationListener {
|
||||
log.info("Benachrichtigung für User {}: Topologie-Check fehlgeschlagen", event.userId());
|
||||
}
|
||||
|
||||
@EventListener
|
||||
@Transactional
|
||||
public void handleCommunityTariffChanged(CommunityTariffChangedEvent event) {
|
||||
List<UUID> memberUserIds = membershipRepository.findActiveMemberUserIdsByCommunityId(
|
||||
event.energyCommunityId());
|
||||
String title = "Community-Tarif geändert";
|
||||
String message = "Der Tarif für Ihre Energiegemeinschaft wurde " +
|
||||
("CREATED".equals(event.changeType()) ? "erstellt" : "aktualisiert") + ".";
|
||||
for (UUID userId : memberUserIds) {
|
||||
notificationService.create(userId, title, message, NotificationType.INFO);
|
||||
}
|
||||
log.info("Benachrichtigung an {} Mitglieder der Community {}: Tarif {}",
|
||||
memberUserIds.size(), event.energyCommunityId(), event.changeType());
|
||||
}
|
||||
|
||||
@EventListener
|
||||
@Transactional
|
||||
public void handleUserTariffChanged(UserTariffChangedEvent event) {
|
||||
String title = "User-Tarif geändert";
|
||||
String messageSuffix = switch (event.changeType()) {
|
||||
case "CREATED" -> "erstellt";
|
||||
case "UPDATED" -> "aktualisiert";
|
||||
case "DELETED" -> "gelöscht";
|
||||
default -> "geändert";
|
||||
};
|
||||
String message = "Ein User-Tarif in Ihrer Energiegemeinschaft wurde " + messageSuffix + ".";
|
||||
|
||||
notificationService.create(event.sourceUserId(), title, message, NotificationType.INFO);
|
||||
if (!event.sourceUserId().equals(event.targetUserId())) {
|
||||
notificationService.create(event.targetUserId(), title, message, NotificationType.INFO);
|
||||
}
|
||||
log.info("Benachrichtigung an User {}, {}: User-Tarif {}",
|
||||
event.sourceUserId(), event.targetUserId(), event.changeType());
|
||||
}
|
||||
|
||||
private java.util.Optional<MeteringPoint> findMeteringPointByAtNumber(String atNumber) {
|
||||
return meteringPointRepository.findByAtNumber(atNumber);
|
||||
}
|
||||
|
||||
@ -36,4 +36,7 @@ public interface MembershipRepository extends JpaRepository<Membership, UUID> {
|
||||
|
||||
@Query("SELECT m FROM Membership m JOIN FETCH m.meteringPoint mp JOIN FETCH m.energyCommunity ec WHERE m.status = 'PENDING'")
|
||||
List<Membership> findPendingWithDetails();
|
||||
|
||||
@Query("SELECT mp.userId FROM Membership m JOIN m.meteringPoint mp WHERE m.energyCommunity.id = :communityId AND m.status != 'INACTIVE'")
|
||||
List<UUID> findActiveMemberUserIdsByCommunityId(@Param("communityId") UUID communityId);
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package at.mueller.eeg.backend.tariff.api;
|
||||
|
||||
import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffRequest;
|
||||
import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffResponse;
|
||||
import at.mueller.eeg.backend.tariff.service.TariffService;
|
||||
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.web.bind.annotation.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/energy-communities")
|
||||
@RequiredArgsConstructor
|
||||
public class TariffController {
|
||||
|
||||
private final TariffService tariffService;
|
||||
|
||||
@PutMapping(value = "/{communityId}/tariff", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<CommunityTariffResponse> createOrUpdateCommunityTariff(
|
||||
@PathVariable UUID communityId,
|
||||
@Valid @RequestBody CommunityTariffRequest request) {
|
||||
CommunityTariffResponse response = tariffService.createOrUpdateCommunityTariff(communityId, request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{communityId}/tariff", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<CommunityTariffResponse> getCommunityTariff(@PathVariable UUID communityId) {
|
||||
return ResponseEntity.ok(tariffService.getCommunityTariff(communityId));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package at.mueller.eeg.backend.tariff.api;
|
||||
|
||||
import at.mueller.eeg.backend.tariff.api.dto.UserTariffRequest;
|
||||
import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse;
|
||||
import at.mueller.eeg.backend.tariff.service.TariffService;
|
||||
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.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tariffs")
|
||||
@RequiredArgsConstructor
|
||||
public class UserTariffController {
|
||||
|
||||
private final TariffService tariffService;
|
||||
|
||||
@PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<UserTariffResponse> createUserTariff(
|
||||
@Valid @RequestBody UserTariffRequest request) {
|
||||
// userId would come from SecurityContext in real app; here passed as query param or header
|
||||
// For now, we accept it from X-User-Id header (set by gateway or test)
|
||||
UUID userId = extractUserId(request);
|
||||
UserTariffResponse response = tariffService.createUserTariff(userId, request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@PutMapping(value = "/{tariffId}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<UserTariffResponse> updateUserTariff(
|
||||
@PathVariable UUID tariffId,
|
||||
@Valid @RequestBody UserTariffRequest request) {
|
||||
UUID userId = extractUserId(request);
|
||||
UserTariffResponse response = tariffService.updateUserTariff(userId, tariffId, request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{tariffId}")
|
||||
public ResponseEntity<Void> deleteUserTariff(
|
||||
@PathVariable UUID tariffId,
|
||||
@RequestParam UUID userId) {
|
||||
tariffService.deleteUserTariff(userId, tariffId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping(value = "/community/{communityId}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<List<UserTariffResponse>> getUserTariffsForCommunity(
|
||||
@PathVariable UUID communityId) {
|
||||
return ResponseEntity.ok(tariffService.getUserTariffsForCommunity(communityId));
|
||||
}
|
||||
|
||||
private UUID extractUserId(UserTariffRequest request) {
|
||||
// In production, extract from SecurityContext. For now, derive from source metering point.
|
||||
// This is a placeholder - real implementation would use @AuthenticationPrincipal
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package at.mueller.eeg.backend.tariff.api.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CommunityTariffRequest(
|
||||
@NotNull @DecimalMin("0.0001") BigDecimal maxPricePerKwhCents,
|
||||
@NotNull @DecimalMin("0") @DecimalMax("100") BigDecimal surchargePercent,
|
||||
LocalDate validFrom,
|
||||
LocalDate validTo
|
||||
) {}
|
||||
@ -0,0 +1,17 @@
|
||||
package at.mueller.eeg.backend.tariff.api.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CommunityTariffResponse(
|
||||
UUID id,
|
||||
UUID energyCommunityId,
|
||||
BigDecimal maxPricePerKwhCents,
|
||||
BigDecimal surchargePercent,
|
||||
LocalDate validFrom,
|
||||
LocalDate validTo,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {}
|
||||
@ -0,0 +1,17 @@
|
||||
package at.mueller.eeg.backend.tariff.api.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record UserTariffRequest(
|
||||
@NotNull UUID energyCommunityId,
|
||||
@NotNull UUID sourceMeteringPointId,
|
||||
@NotNull UUID targetMeteringPointId,
|
||||
@NotNull @DecimalMin("0.0001") BigDecimal pricePerKwhCents,
|
||||
LocalDate validFrom,
|
||||
LocalDate validTo
|
||||
) {}
|
||||
@ -0,0 +1,17 @@
|
||||
package at.mueller.eeg.backend.tariff.api.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record UserTariffResponse(
|
||||
UUID id,
|
||||
UUID energyCommunityId,
|
||||
UUID sourceMeteringPointId,
|
||||
UUID targetMeteringPointId,
|
||||
BigDecimal pricePerKwhCents,
|
||||
LocalDate validFrom,
|
||||
LocalDate validTo,
|
||||
LocalDateTime createdAt
|
||||
) {}
|
||||
@ -0,0 +1,49 @@
|
||||
package at.mueller.eeg.backend.tariff.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "community_tariffs", uniqueConstraints = @UniqueConstraint(columnNames = "energy_community_id"))
|
||||
@Getter
|
||||
@Setter
|
||||
public class CommunityTariff {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "energy_community_id", nullable = false, unique = true)
|
||||
private UUID energyCommunityId;
|
||||
|
||||
@Column(name = "max_price_per_kwh_cents", nullable = false, precision = 10, scale = 4)
|
||||
private BigDecimal maxPricePerKwhCents;
|
||||
|
||||
@Column(name = "surcharge_percent", nullable = false, precision = 5, scale = 2)
|
||||
private BigDecimal surchargePercent;
|
||||
|
||||
private LocalDate validFrom;
|
||||
private LocalDate validTo;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -6,13 +6,14 @@ import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tariffs")
|
||||
@Table(name = "user_tariffs")
|
||||
@Getter
|
||||
@Setter
|
||||
public class Tariff {
|
||||
public class UserTariff {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
@ -20,16 +21,23 @@ public class Tariff {
|
||||
@Column(name = "energy_community_id", nullable = false)
|
||||
private UUID energyCommunityId;
|
||||
|
||||
@Column(name = "source_metering_point_id")
|
||||
@Column(name = "source_metering_point_id", nullable = false)
|
||||
private UUID sourceMeteringPointId;
|
||||
|
||||
@Column(name = "target_metering_point_id")
|
||||
@Column(name = "target_metering_point_id", nullable = false)
|
||||
private UUID targetMeteringPointId;
|
||||
|
||||
@Column(nullable = false, precision = 10, scale = 4)
|
||||
@Column(name = "price_per_kwh_cents", nullable = false, precision = 10, scale = 4)
|
||||
private BigDecimal pricePerKwhCents;
|
||||
|
||||
private LocalDate validFrom;
|
||||
|
||||
private LocalDate validTo;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package at.mueller.eeg.backend.tariff.repository;
|
||||
|
||||
import at.mueller.eeg.backend.tariff.domain.CommunityTariff;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface CommunityTariffRepository extends JpaRepository<CommunityTariff, UUID> {
|
||||
Optional<CommunityTariff> findByEnergyCommunityId(UUID energyCommunityId);
|
||||
boolean existsByEnergyCommunityId(UUID energyCommunityId);
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package at.mueller.eeg.backend.tariff.repository;
|
||||
|
||||
import at.mueller.eeg.backend.tariff.domain.UserTariff;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface UserTariffRepository extends JpaRepository<UserTariff, UUID> {
|
||||
List<UserTariff> findByEnergyCommunityId(UUID energyCommunityId);
|
||||
List<UserTariff> findBySourceMeteringPointIdOrTargetMeteringPointId(UUID sourceId, UUID targetId);
|
||||
Optional<UserTariff> findBySourceMeteringPointIdAndTargetMeteringPointId(UUID sourceId, UUID targetId);
|
||||
}
|
||||
@ -0,0 +1,262 @@
|
||||
package at.mueller.eeg.backend.tariff.service;
|
||||
|
||||
import at.mueller.eeg.backend.common.event.CommunityTariffChangedEvent;
|
||||
import at.mueller.eeg.backend.common.event.UserTariffChangedEvent;
|
||||
import at.mueller.eeg.backend.community.domain.MembershipStatus;
|
||||
import at.mueller.eeg.backend.community.domain.MeteringPoint;
|
||||
import at.mueller.eeg.backend.community.domain.state.MakoState;
|
||||
import at.mueller.eeg.backend.community.repository.MembershipRepository;
|
||||
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
|
||||
import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffRequest;
|
||||
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.UserTariff;
|
||||
import at.mueller.eeg.backend.tariff.repository.CommunityTariffRepository;
|
||||
import at.mueller.eeg.backend.tariff.repository.UserTariffRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional(readOnly = true)
|
||||
public class TariffService {
|
||||
|
||||
private final CommunityTariffRepository communityTariffRepository;
|
||||
private final UserTariffRepository userTariffRepository;
|
||||
private final MeteringPointRepository meteringPointRepository;
|
||||
private final MembershipRepository membershipRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Transactional
|
||||
public CommunityTariffResponse createOrUpdateCommunityTariff(
|
||||
UUID communityId, CommunityTariffRequest request) {
|
||||
|
||||
CommunityTariff tariff = communityTariffRepository.findByEnergyCommunityId(communityId)
|
||||
.orElse(new CommunityTariff());
|
||||
|
||||
boolean isNew = tariff.getId() == null;
|
||||
tariff.setEnergyCommunityId(communityId);
|
||||
tariff.setMaxPricePerKwhCents(request.maxPricePerKwhCents());
|
||||
tariff.setSurchargePercent(request.surchargePercent());
|
||||
tariff.setValidFrom(request.validFrom());
|
||||
tariff.setValidTo(request.validTo());
|
||||
|
||||
CommunityTariff saved = communityTariffRepository.save(tariff);
|
||||
log.info("Community-Tarif {} für Community {}: maxPrice={}, Aufschlag={}%",
|
||||
isNew ? "erstellt" : "aktualisiert", communityId,
|
||||
saved.getMaxPricePerKwhCents(), saved.getSurchargePercent());
|
||||
|
||||
eventPublisher.publishEvent(new CommunityTariffChangedEvent(
|
||||
communityId, isNew ? "CREATED" : "UPDATED"));
|
||||
|
||||
return toCommunityTariffResponse(saved);
|
||||
}
|
||||
|
||||
public CommunityTariffResponse getCommunityTariff(UUID communityId) {
|
||||
CommunityTariff tariff = communityTariffRepository.findByEnergyCommunityId(communityId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Kein Tarif für die Energiegemeinschaft gefunden: " + communityId));
|
||||
return toCommunityTariffResponse(tariff);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserTariffResponse createUserTariff(UUID userId, UserTariffRequest request) {
|
||||
if (request.sourceMeteringPointId().equals(request.targetMeteringPointId())) {
|
||||
throw new IllegalStateException("Quell- und Ziel-Zählpunkt müssen unterschiedlich sein.");
|
||||
}
|
||||
|
||||
MeteringPoint sourcePoint = meteringPointRepository.findById(request.sourceMeteringPointId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Quell-Zählpunkt nicht gefunden: " + request.sourceMeteringPointId()));
|
||||
MeteringPoint targetPoint = meteringPointRepository.findById(request.targetMeteringPointId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Ziel-Zählpunkt nicht gefunden: " + request.targetMeteringPointId()));
|
||||
|
||||
if (sourcePoint.getMakoState() != MakoState.ACTIVE) {
|
||||
throw new IllegalStateException("Quell-Zählpunkt ist nicht aktiv.");
|
||||
}
|
||||
if (targetPoint.getMakoState() != MakoState.ACTIVE) {
|
||||
throw new IllegalStateException("Ziel-Zählpunkt ist nicht aktiv.");
|
||||
}
|
||||
|
||||
CommunityTariff communityTariff = communityTariffRepository.findByEnergyCommunityId(
|
||||
request.energyCommunityId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Kein Community-Tarif für Energiegemeinschaft vorhanden: " + request.energyCommunityId()));
|
||||
|
||||
if (request.pricePerKwhCents().compareTo(communityTariff.getMaxPricePerKwhCents()) > 0) {
|
||||
throw new IllegalStateException(
|
||||
"Preis darf den Maximalpreis des Community-Tarifs nicht überschreiten. " +
|
||||
"Maximalpreis: " + communityTariff.getMaxPricePerKwhCents() + " Cent/kWh");
|
||||
}
|
||||
|
||||
UUID sourceUserId = sourcePoint.getUserId();
|
||||
UUID targetUserId = targetPoint.getUserId();
|
||||
|
||||
if (!isMemberOfCommunity(sourceUserId, request.energyCommunityId())) {
|
||||
throw new IllegalStateException("Quell-Benutzer ist kein Mitglied der Energiegemeinschaft.");
|
||||
}
|
||||
if (!isMemberOfCommunity(targetUserId, request.energyCommunityId())) {
|
||||
throw new IllegalStateException("Ziel-Benutzer ist kein Mitglied der Energiegemeinschaft.");
|
||||
}
|
||||
|
||||
UserTariff tariff = new UserTariff();
|
||||
tariff.setEnergyCommunityId(request.energyCommunityId());
|
||||
tariff.setSourceMeteringPointId(request.sourceMeteringPointId());
|
||||
tariff.setTargetMeteringPointId(request.targetMeteringPointId());
|
||||
tariff.setPricePerKwhCents(request.pricePerKwhCents());
|
||||
tariff.setValidFrom(request.validFrom());
|
||||
tariff.setValidTo(request.validTo());
|
||||
|
||||
UserTariff saved = userTariffRepository.save(tariff);
|
||||
log.info("User-Tarif erstellt: Community={}, Source={}, Target={}, Preis={} Cent/kWh",
|
||||
request.energyCommunityId(), request.sourceMeteringPointId(),
|
||||
request.targetMeteringPointId(), request.pricePerKwhCents());
|
||||
|
||||
eventPublisher.publishEvent(new UserTariffChangedEvent(
|
||||
request.energyCommunityId(), sourceUserId, targetUserId, "CREATED"));
|
||||
|
||||
return toUserTariffResponse(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserTariffResponse updateUserTariff(UUID userId, UUID tariffId, UserTariffRequest request) {
|
||||
UserTariff tariff = userTariffRepository.findById(tariffId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User-Tarif nicht gefunden: " + tariffId));
|
||||
|
||||
MeteringPoint sourcePoint = meteringPointRepository.findById(tariff.getSourceMeteringPointId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Quell-Zählpunkt nicht gefunden."));
|
||||
|
||||
if (!sourcePoint.getUserId().equals(userId)) {
|
||||
throw new org.springframework.security.access.AccessDeniedException(
|
||||
"Keine Berechtigung zum Bearbeiten dieses Tarifs.");
|
||||
}
|
||||
|
||||
if (request.sourceMeteringPointId().equals(request.targetMeteringPointId())) {
|
||||
throw new IllegalStateException("Quell- und Ziel-Zählpunkt müssen unterschiedlich sein.");
|
||||
}
|
||||
|
||||
MeteringPoint newSourcePoint = meteringPointRepository.findById(request.sourceMeteringPointId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Quell-Zählpunkt nicht gefunden: " + request.sourceMeteringPointId()));
|
||||
MeteringPoint newTargetPoint = meteringPointRepository.findById(request.targetMeteringPointId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Ziel-Zählpunkt nicht gefunden: " + request.targetMeteringPointId()));
|
||||
|
||||
if (newSourcePoint.getMakoState() != MakoState.ACTIVE) {
|
||||
throw new IllegalStateException("Quell-Zählpunkt ist nicht aktiv.");
|
||||
}
|
||||
if (newTargetPoint.getMakoState() != MakoState.ACTIVE) {
|
||||
throw new IllegalStateException("Ziel-Zählpunkt ist nicht aktiv.");
|
||||
}
|
||||
|
||||
CommunityTariff communityTariff = communityTariffRepository.findByEnergyCommunityId(
|
||||
request.energyCommunityId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Kein Community-Tarif für Energiegemeinschaft vorhanden: " + request.energyCommunityId()));
|
||||
|
||||
if (request.pricePerKwhCents().compareTo(communityTariff.getMaxPricePerKwhCents()) > 0) {
|
||||
throw new IllegalStateException(
|
||||
"Preis darf den Maximalpreis des Community-Tarifs nicht überschreiten. " +
|
||||
"Maximalpreis: " + communityTariff.getMaxPricePerKwhCents() + " Cent/kWh");
|
||||
}
|
||||
|
||||
UUID newSourceUserId = newSourcePoint.getUserId();
|
||||
UUID newTargetUserId = newTargetPoint.getUserId();
|
||||
|
||||
if (!isMemberOfCommunity(newSourceUserId, request.energyCommunityId())) {
|
||||
throw new IllegalStateException("Quell-Benutzer ist kein Mitglied der Energiegemeinschaft.");
|
||||
}
|
||||
if (!isMemberOfCommunity(newTargetUserId, request.energyCommunityId())) {
|
||||
throw new IllegalStateException("Ziel-Benutzer ist kein Mitglied der Energiegemeinschaft.");
|
||||
}
|
||||
|
||||
tariff.setEnergyCommunityId(request.energyCommunityId());
|
||||
tariff.setSourceMeteringPointId(request.sourceMeteringPointId());
|
||||
tariff.setTargetMeteringPointId(request.targetMeteringPointId());
|
||||
tariff.setPricePerKwhCents(request.pricePerKwhCents());
|
||||
tariff.setValidFrom(request.validFrom());
|
||||
tariff.setValidTo(request.validTo());
|
||||
|
||||
UserTariff saved = userTariffRepository.save(tariff);
|
||||
log.info("User-Tarif aktualisiert: id={}, Preis={} Cent/kWh",
|
||||
tariffId, request.pricePerKwhCents());
|
||||
|
||||
eventPublisher.publishEvent(new UserTariffChangedEvent(
|
||||
request.energyCommunityId(), newSourceUserId, newTargetUserId, "UPDATED"));
|
||||
|
||||
return toUserTariffResponse(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteUserTariff(UUID userId, UUID tariffId) {
|
||||
UserTariff tariff = userTariffRepository.findById(tariffId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User-Tarif nicht gefunden: " + tariffId));
|
||||
|
||||
MeteringPoint sourcePoint = meteringPointRepository.findById(tariff.getSourceMeteringPointId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Quell-Zählpunkt nicht gefunden."));
|
||||
|
||||
if (!sourcePoint.getUserId().equals(userId)) {
|
||||
throw new org.springframework.security.access.AccessDeniedException(
|
||||
"Keine Berechtigung zum Löschen dieses Tarifs.");
|
||||
}
|
||||
|
||||
UUID sourceUserId = sourcePoint.getUserId();
|
||||
MeteringPoint targetPoint = meteringPointRepository.findById(tariff.getTargetMeteringPointId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Ziel-Zählpunkt nicht gefunden."));
|
||||
UUID targetUserId = targetPoint.getUserId();
|
||||
|
||||
userTariffRepository.deleteById(tariffId);
|
||||
log.info("User-Tarif gelöscht: id={}, Community={}", tariffId, tariff.getEnergyCommunityId());
|
||||
|
||||
eventPublisher.publishEvent(new UserTariffChangedEvent(
|
||||
tariff.getEnergyCommunityId(), sourceUserId, targetUserId, "DELETED"));
|
||||
}
|
||||
|
||||
public List<UserTariffResponse> getUserTariffsForCommunity(UUID communityId) {
|
||||
return userTariffRepository.findByEnergyCommunityId(communityId).stream()
|
||||
.map(this::toUserTariffResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean isMemberOfCommunity(UUID userId, UUID communityId) {
|
||||
return membershipRepository.findByUserIdWithDetails(userId).stream()
|
||||
.anyMatch(m -> m.getEnergyCommunity().getId().equals(communityId)
|
||||
&& m.getStatus() != MembershipStatus.INACTIVE);
|
||||
}
|
||||
|
||||
private CommunityTariffResponse toCommunityTariffResponse(CommunityTariff tariff) {
|
||||
return new CommunityTariffResponse(
|
||||
tariff.getId(),
|
||||
tariff.getEnergyCommunityId(),
|
||||
tariff.getMaxPricePerKwhCents(),
|
||||
tariff.getSurchargePercent(),
|
||||
tariff.getValidFrom(),
|
||||
tariff.getValidTo(),
|
||||
tariff.getCreatedAt(),
|
||||
tariff.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private UserTariffResponse toUserTariffResponse(UserTariff tariff) {
|
||||
return new UserTariffResponse(
|
||||
tariff.getId(),
|
||||
tariff.getEnergyCommunityId(),
|
||||
tariff.getSourceMeteringPointId(),
|
||||
tariff.getTargetMeteringPointId(),
|
||||
tariff.getPricePerKwhCents(),
|
||||
tariff.getValidFrom(),
|
||||
tariff.getValidTo(),
|
||||
tariff.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,623 @@
|
||||
package at.mueller.eeg.backend.tariff.service;
|
||||
|
||||
import at.mueller.eeg.backend.common.event.CommunityTariffChangedEvent;
|
||||
import at.mueller.eeg.backend.common.event.UserTariffChangedEvent;
|
||||
import at.mueller.eeg.backend.community.domain.CommunityType;
|
||||
import at.mueller.eeg.backend.community.domain.EnergyCommunity;
|
||||
import at.mueller.eeg.backend.community.domain.Membership;
|
||||
import at.mueller.eeg.backend.community.domain.MembershipStatus;
|
||||
import at.mueller.eeg.backend.community.domain.MeteringPoint;
|
||||
import at.mueller.eeg.backend.community.domain.state.MakoState;
|
||||
import at.mueller.eeg.backend.community.repository.MembershipRepository;
|
||||
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
|
||||
import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffRequest;
|
||||
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.UserTariff;
|
||||
import at.mueller.eeg.backend.tariff.repository.CommunityTariffRepository;
|
||||
import at.mueller.eeg.backend.tariff.repository.UserTariffRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
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 TariffServiceTest {
|
||||
|
||||
@Mock
|
||||
private CommunityTariffRepository communityTariffRepository;
|
||||
@Mock
|
||||
private UserTariffRepository userTariffRepository;
|
||||
@Mock
|
||||
private MeteringPointRepository meteringPointRepository;
|
||||
@Mock
|
||||
private MembershipRepository membershipRepository;
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@InjectMocks
|
||||
private TariffService tariffService;
|
||||
|
||||
private UUID communityId;
|
||||
private UUID sourceUserId;
|
||||
private UUID targetUserId;
|
||||
private UUID sourceMeteringPointId;
|
||||
private UUID targetMeteringPointId;
|
||||
private EnergyCommunity testCommunity;
|
||||
private MeteringPoint sourcePoint;
|
||||
private MeteringPoint targetPoint;
|
||||
private CommunityTariff communityTariff;
|
||||
private Membership sourceMembership;
|
||||
private Membership targetMembership;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
communityId = UUID.randomUUID();
|
||||
sourceUserId = UUID.randomUUID();
|
||||
targetUserId = UUID.randomUUID();
|
||||
sourceMeteringPointId = UUID.randomUUID();
|
||||
targetMeteringPointId = UUID.randomUUID();
|
||||
|
||||
testCommunity = new EnergyCommunity();
|
||||
testCommunity.setId(communityId);
|
||||
testCommunity.setName("Test EEG");
|
||||
testCommunity.setType(CommunityType.EEG_REGIONAL);
|
||||
|
||||
sourcePoint = new MeteringPoint();
|
||||
sourcePoint.setId(sourceMeteringPointId);
|
||||
sourcePoint.setUserId(sourceUserId);
|
||||
sourcePoint.setAtNumber("AT0010000000000000000000001234567");
|
||||
sourcePoint.setMakoState(MakoState.ACTIVE);
|
||||
|
||||
targetPoint = new MeteringPoint();
|
||||
targetPoint.setId(targetMeteringPointId);
|
||||
targetPoint.setUserId(targetUserId);
|
||||
targetPoint.setAtNumber("AT0010000000000000000000001234568");
|
||||
targetPoint.setMakoState(MakoState.ACTIVE);
|
||||
|
||||
communityTariff = new CommunityTariff();
|
||||
communityTariff.setId(UUID.randomUUID());
|
||||
communityTariff.setEnergyCommunityId(communityId);
|
||||
communityTariff.setMaxPricePerKwhCents(new BigDecimal("25.0000"));
|
||||
communityTariff.setSurchargePercent(new BigDecimal("10.00"));
|
||||
|
||||
sourceMembership = new Membership();
|
||||
sourceMembership.setId(UUID.randomUUID());
|
||||
sourceMembership.setMeteringPoint(sourcePoint);
|
||||
sourceMembership.setEnergyCommunity(testCommunity);
|
||||
sourceMembership.setStatus(MembershipStatus.ACTIVE);
|
||||
|
||||
targetMembership = new Membership();
|
||||
targetMembership.setId(UUID.randomUUID());
|
||||
targetMembership.setMeteringPoint(targetPoint);
|
||||
targetMembership.setEnergyCommunity(testCommunity);
|
||||
targetMembership.setStatus(MembershipStatus.ACTIVE);
|
||||
}
|
||||
|
||||
// --- Community Tariff Tests ---
|
||||
|
||||
@Test
|
||||
void createCommunityTariff_createsNew() {
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.empty());
|
||||
when(communityTariffRepository.save(any(CommunityTariff.class))).thenReturn(communityTariff);
|
||||
|
||||
CommunityTariffRequest request = new CommunityTariffRequest(
|
||||
new BigDecimal("25.0000"), new BigDecimal("10.00"),
|
||||
LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
CommunityTariffResponse response = tariffService.createOrUpdateCommunityTariff(communityId, request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(communityId, response.energyCommunityId());
|
||||
assertEquals(new BigDecimal("25.0000"), response.maxPricePerKwhCents());
|
||||
verify(communityTariffRepository).save(any(CommunityTariff.class));
|
||||
verify(eventPublisher).publishEvent(any(CommunityTariffChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateCommunityTariff_updatesExisting() {
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
when(communityTariffRepository.save(any(CommunityTariff.class))).thenReturn(communityTariff);
|
||||
|
||||
CommunityTariffRequest request = new CommunityTariffRequest(
|
||||
new BigDecimal("30.0000"), new BigDecimal("15.00"),
|
||||
LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
CommunityTariffResponse response = tariffService.createOrUpdateCommunityTariff(communityId, request);
|
||||
|
||||
assertNotNull(response);
|
||||
verify(communityTariffRepository).save(communityTariff);
|
||||
verify(eventPublisher).publishEvent(any(CommunityTariffChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCommunityTariff_throwsIfNotFound() {
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tariffService.getCommunityTariff(communityId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCommunityTariff_returnsTariff() {
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
|
||||
CommunityTariffResponse response = tariffService.getCommunityTariff(communityId);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(communityId, response.energyCommunityId());
|
||||
}
|
||||
|
||||
// --- User Tariff Tests ---
|
||||
|
||||
@Test
|
||||
void createUserTariff_success() {
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
|
||||
.thenReturn(List.of(sourceMembership));
|
||||
when(membershipRepository.findByUserIdWithDetails(targetUserId))
|
||||
.thenReturn(List.of(targetMembership));
|
||||
|
||||
UserTariff savedTariff = new UserTariff();
|
||||
savedTariff.setId(UUID.randomUUID());
|
||||
savedTariff.setEnergyCommunityId(communityId);
|
||||
savedTariff.setSourceMeteringPointId(sourceMeteringPointId);
|
||||
savedTariff.setTargetMeteringPointId(targetMeteringPointId);
|
||||
savedTariff.setPricePerKwhCents(new BigDecimal("20.0000"));
|
||||
when(userTariffRepository.save(any(UserTariff.class))).thenReturn(savedTariff);
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
UserTariffResponse response = tariffService.createUserTariff(sourceUserId, request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(communityId, response.energyCommunityId());
|
||||
assertEquals(new BigDecimal("20.0000"), response.pricePerKwhCents());
|
||||
verify(userTariffRepository).save(any(UserTariff.class));
|
||||
verify(eventPublisher).publishEvent(any(UserTariffChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_throwsIfSameMeteringPoints() {
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, sourceMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_throwsIfSourcePointNotFound() {
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_throwsIfTargetPointNotFound() {
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_throwsIfSourcePointNotActive() {
|
||||
sourcePoint.setMakoState(MakoState.WAITING_FOR_CONSENT);
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_throwsIfTargetPointNotActive() {
|
||||
targetPoint.setMakoState(MakoState.REJECTED);
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_throwsIfNoCommunityTariff() {
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_throwsIfPriceExceedsMax() {
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("30.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_throwsIfSourceNotMember() {
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
|
||||
.thenReturn(List.of());
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_throwsIfTargetNotMember() {
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
|
||||
.thenReturn(List.of(sourceMembership));
|
||||
when(membershipRepository.findByUserIdWithDetails(targetUserId))
|
||||
.thenReturn(List.of());
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
// --- Update User Tariff Tests ---
|
||||
|
||||
@Test
|
||||
void updateUserTariff_success() {
|
||||
UserTariff existingTariff = new UserTariff();
|
||||
existingTariff.setId(UUID.randomUUID());
|
||||
existingTariff.setEnergyCommunityId(communityId);
|
||||
existingTariff.setSourceMeteringPointId(sourceMeteringPointId);
|
||||
existingTariff.setTargetMeteringPointId(targetMeteringPointId);
|
||||
existingTariff.setPricePerKwhCents(new BigDecimal("15.0000"));
|
||||
|
||||
when(userTariffRepository.findById(existingTariff.getId()))
|
||||
.thenReturn(Optional.of(existingTariff));
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
|
||||
.thenReturn(List.of(sourceMembership));
|
||||
when(membershipRepository.findByUserIdWithDetails(targetUserId))
|
||||
.thenReturn(List.of(targetMembership));
|
||||
when(userTariffRepository.save(any(UserTariff.class))).thenReturn(existingTariff);
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("22.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
UserTariffResponse response = tariffService.updateUserTariff(
|
||||
sourceUserId, existingTariff.getId(), request);
|
||||
|
||||
assertNotNull(response);
|
||||
verify(userTariffRepository).save(any(UserTariff.class));
|
||||
verify(eventPublisher).publishEvent(any(UserTariffChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserTariff_throwsIfNotOwner() {
|
||||
UserTariff existingTariff = new UserTariff();
|
||||
existingTariff.setId(UUID.randomUUID());
|
||||
existingTariff.setSourceMeteringPointId(sourceMeteringPointId);
|
||||
|
||||
MeteringPoint otherUserPoint = new MeteringPoint();
|
||||
otherUserPoint.setId(sourceMeteringPointId);
|
||||
otherUserPoint.setUserId(UUID.randomUUID());
|
||||
|
||||
when(userTariffRepository.findById(existingTariff.getId()))
|
||||
.thenReturn(Optional.of(existingTariff));
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(otherUserPoint));
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> tariffService.updateUserTariff(sourceUserId, existingTariff.getId(), request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserTariff_throwsIfTariffNotFound() {
|
||||
when(userTariffRepository.findById(any()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tariffService.updateUserTariff(sourceUserId, UUID.randomUUID(), request));
|
||||
}
|
||||
|
||||
// --- Delete User Tariff Tests ---
|
||||
|
||||
@Test
|
||||
void deleteUserTariff_success() {
|
||||
UserTariff existingTariff = new UserTariff();
|
||||
existingTariff.setId(UUID.randomUUID());
|
||||
existingTariff.setEnergyCommunityId(communityId);
|
||||
existingTariff.setSourceMeteringPointId(sourceMeteringPointId);
|
||||
existingTariff.setTargetMeteringPointId(targetMeteringPointId);
|
||||
|
||||
when(userTariffRepository.findById(existingTariff.getId()))
|
||||
.thenReturn(Optional.of(existingTariff));
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
|
||||
tariffService.deleteUserTariff(sourceUserId, existingTariff.getId());
|
||||
|
||||
verify(userTariffRepository).deleteById(existingTariff.getId());
|
||||
verify(eventPublisher).publishEvent(any(UserTariffChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserTariff_throwsIfNotOwner() {
|
||||
UserTariff existingTariff = new UserTariff();
|
||||
existingTariff.setId(UUID.randomUUID());
|
||||
existingTariff.setSourceMeteringPointId(sourceMeteringPointId);
|
||||
|
||||
MeteringPoint otherUserPoint = new MeteringPoint();
|
||||
otherUserPoint.setId(sourceMeteringPointId);
|
||||
otherUserPoint.setUserId(UUID.randomUUID());
|
||||
|
||||
when(userTariffRepository.findById(existingTariff.getId()))
|
||||
.thenReturn(Optional.of(existingTariff));
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(otherUserPoint));
|
||||
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> tariffService.deleteUserTariff(sourceUserId, existingTariff.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserTariff_throwsIfNotFound() {
|
||||
when(userTariffRepository.findById(any()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tariffService.deleteUserTariff(sourceUserId, UUID.randomUUID()));
|
||||
}
|
||||
|
||||
// --- Get User Tariffs Tests ---
|
||||
|
||||
@Test
|
||||
void getUserTariffsForCommunity_returnsList() {
|
||||
UserTariff tariff = new UserTariff();
|
||||
tariff.setId(UUID.randomUUID());
|
||||
tariff.setEnergyCommunityId(communityId);
|
||||
tariff.setSourceMeteringPointId(sourceMeteringPointId);
|
||||
tariff.setTargetMeteringPointId(targetMeteringPointId);
|
||||
tariff.setPricePerKwhCents(new BigDecimal("20.0000"));
|
||||
|
||||
when(userTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(List.of(tariff));
|
||||
|
||||
List<UserTariffResponse> response = tariffService.getUserTariffsForCommunity(communityId);
|
||||
|
||||
assertEquals(1, response.size());
|
||||
assertEquals(communityId, response.get(0).energyCommunityId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUserTariffsForCommunity_returnsEmptyList() {
|
||||
when(userTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(List.of());
|
||||
|
||||
List<UserTariffResponse> response = tariffService.getUserTariffsForCommunity(communityId);
|
||||
|
||||
assertTrue(response.isEmpty());
|
||||
}
|
||||
|
||||
// --- Price boundary tests ---
|
||||
|
||||
@Test
|
||||
void createUserTariff_priceExactlyAtMax_succeeds() {
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
|
||||
.thenReturn(List.of(sourceMembership));
|
||||
when(membershipRepository.findByUserIdWithDetails(targetUserId))
|
||||
.thenReturn(List.of(targetMembership));
|
||||
|
||||
UserTariff savedTariff = new UserTariff();
|
||||
savedTariff.setId(UUID.randomUUID());
|
||||
when(userTariffRepository.save(any(UserTariff.class))).thenReturn(savedTariff);
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("25.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertDoesNotThrow(() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_priceOneCentOverMax_throws() {
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("25.0001"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
// --- Inactive membership tests ---
|
||||
|
||||
@Test
|
||||
void createUserTariff_throwsIfSourceMembershipInactive() {
|
||||
sourceMembership.setStatus(MembershipStatus.INACTIVE);
|
||||
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
|
||||
.thenReturn(List.of(sourceMembership));
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> tariffService.createUserTariff(sourceUserId, request));
|
||||
}
|
||||
|
||||
// --- Event verification tests ---
|
||||
|
||||
@Test
|
||||
void createCommunityTariff_publishesEvent() {
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.empty());
|
||||
when(communityTariffRepository.save(any(CommunityTariff.class))).thenReturn(communityTariff);
|
||||
|
||||
CommunityTariffRequest request = new CommunityTariffRequest(
|
||||
new BigDecimal("25.0000"), new BigDecimal("10.00"),
|
||||
LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
tariffService.createOrUpdateCommunityTariff(communityId, request);
|
||||
|
||||
ArgumentCaptor<CommunityTariffChangedEvent> captor =
|
||||
ArgumentCaptor.forClass(CommunityTariffChangedEvent.class);
|
||||
verify(eventPublisher).publishEvent(captor.capture());
|
||||
assertEquals("CREATED", captor.getValue().changeType());
|
||||
assertEquals(communityId, captor.getValue().energyCommunityId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserTariff_publishesEventWithCorrectUserIds() {
|
||||
when(meteringPointRepository.findById(sourceMeteringPointId))
|
||||
.thenReturn(Optional.of(sourcePoint));
|
||||
when(meteringPointRepository.findById(targetMeteringPointId))
|
||||
.thenReturn(Optional.of(targetPoint));
|
||||
when(communityTariffRepository.findByEnergyCommunityId(communityId))
|
||||
.thenReturn(Optional.of(communityTariff));
|
||||
when(membershipRepository.findByUserIdWithDetails(sourceUserId))
|
||||
.thenReturn(List.of(sourceMembership));
|
||||
when(membershipRepository.findByUserIdWithDetails(targetUserId))
|
||||
.thenReturn(List.of(targetMembership));
|
||||
|
||||
UserTariff savedTariff = new UserTariff();
|
||||
savedTariff.setId(UUID.randomUUID());
|
||||
when(userTariffRepository.save(any(UserTariff.class))).thenReturn(savedTariff);
|
||||
|
||||
UserTariffRequest request = new UserTariffRequest(
|
||||
communityId, sourceMeteringPointId, targetMeteringPointId,
|
||||
new BigDecimal("20.0000"), LocalDate.now(), LocalDate.now().plusYears(1));
|
||||
|
||||
tariffService.createUserTariff(sourceUserId, request);
|
||||
|
||||
ArgumentCaptor<UserTariffChangedEvent> captor =
|
||||
ArgumentCaptor.forClass(UserTariffChangedEvent.class);
|
||||
verify(eventPublisher).publishEvent(captor.capture());
|
||||
assertEquals("CREATED", captor.getValue().changeType());
|
||||
assertEquals(sourceUserId, captor.getValue().sourceUserId());
|
||||
assertEquals(targetUserId, captor.getValue().targetUserId());
|
||||
}
|
||||
}
|
||||
@ -74,6 +74,51 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
/api/tariffs/{tariffId}:
|
||||
put:
|
||||
tags:
|
||||
- user-tariff-controller
|
||||
operationId: updateUserTariff
|
||||
parameters:
|
||||
- name: tariffId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserTariffRequest"
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserTariffResponse"
|
||||
delete:
|
||||
tags:
|
||||
- user-tariff-controller
|
||||
operationId: deleteUserTariff
|
||||
parameters:
|
||||
- name: tariffId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: userId
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
/api/notifications/{id}/read:
|
||||
put:
|
||||
tags:
|
||||
@ -223,6 +268,67 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
/api/admin/energy-communities/{communityId}/tariff:
|
||||
get:
|
||||
tags:
|
||||
- tariff-controller
|
||||
operationId: getCommunityTariff
|
||||
parameters:
|
||||
- name: communityId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommunityTariffResponse"
|
||||
put:
|
||||
tags:
|
||||
- tariff-controller
|
||||
operationId: createOrUpdateCommunityTariff
|
||||
parameters:
|
||||
- name: communityId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommunityTariffRequest"
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommunityTariffResponse"
|
||||
/api/tariffs:
|
||||
post:
|
||||
tags:
|
||||
- user-tariff-controller
|
||||
operationId: createUserTariff
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserTariffRequest"
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserTariffResponse"
|
||||
/api/community/metering-points:
|
||||
get:
|
||||
tags:
|
||||
@ -405,6 +511,27 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EnergyCommunityDto"
|
||||
/api/tariffs/community/{communityId}:
|
||||
get:
|
||||
tags:
|
||||
- user-tariff-controller
|
||||
operationId: getUserTariffsForCommunity
|
||||
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/UserTariffResponse"
|
||||
/api/notifications:
|
||||
get:
|
||||
tags:
|
||||
@ -630,6 +757,58 @@ components:
|
||||
minLength: 1
|
||||
required:
|
||||
- newEmail
|
||||
UserTariffRequest:
|
||||
type: object
|
||||
properties:
|
||||
energyCommunityId:
|
||||
type: string
|
||||
format: uuid
|
||||
sourceMeteringPointId:
|
||||
type: string
|
||||
format: uuid
|
||||
targetMeteringPointId:
|
||||
type: string
|
||||
format: uuid
|
||||
pricePerKwhCents:
|
||||
type: number
|
||||
minimum: 0.0001
|
||||
validFrom:
|
||||
type: string
|
||||
format: date
|
||||
validTo:
|
||||
type: string
|
||||
format: date
|
||||
required:
|
||||
- energyCommunityId
|
||||
- pricePerKwhCents
|
||||
- sourceMeteringPointId
|
||||
- targetMeteringPointId
|
||||
UserTariffResponse:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
energyCommunityId:
|
||||
type: string
|
||||
format: uuid
|
||||
sourceMeteringPointId:
|
||||
type: string
|
||||
format: uuid
|
||||
targetMeteringPointId:
|
||||
type: string
|
||||
format: uuid
|
||||
pricePerKwhCents:
|
||||
type: number
|
||||
validFrom:
|
||||
type: string
|
||||
format: date
|
||||
validTo:
|
||||
type: string
|
||||
format: date
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
UpdateMeteringPointRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -696,6 +875,50 @@ components:
|
||||
memberCount:
|
||||
type: integer
|
||||
format: int32
|
||||
CommunityTariffRequest:
|
||||
type: object
|
||||
properties:
|
||||
maxPricePerKwhCents:
|
||||
type: number
|
||||
minimum: 0.0001
|
||||
surchargePercent:
|
||||
type: number
|
||||
maximum: 100
|
||||
minimum: 0
|
||||
validFrom:
|
||||
type: string
|
||||
format: date
|
||||
validTo:
|
||||
type: string
|
||||
format: date
|
||||
required:
|
||||
- maxPricePerKwhCents
|
||||
- surchargePercent
|
||||
CommunityTariffResponse:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
energyCommunityId:
|
||||
type: string
|
||||
format: uuid
|
||||
maxPricePerKwhCents:
|
||||
type: number
|
||||
surchargePercent:
|
||||
type: number
|
||||
validFrom:
|
||||
type: string
|
||||
format: date
|
||||
validTo:
|
||||
type: string
|
||||
format: date
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
CreateMeteringPointRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@ -16,6 +16,8 @@ import {MyMembershipsComponent} from './pages/my-memberships/my-memberships';
|
||||
import {AdminMembershipsComponent} from './pages/admin-memberships/admin-memberships';
|
||||
import {ForgotPasswordComponent} from './pages/forgot-password/forgot-password';
|
||||
import {ResetPasswordComponent} from './pages/reset-password/reset-password';
|
||||
import {AdminTariffComponent} from './pages/admin-tariff/admin-tariff';
|
||||
import {UserTariffComponent} from './pages/user-tariff/user-tariff';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: LandingPage },
|
||||
@ -60,6 +62,16 @@ export const routes: Routes = [
|
||||
component: AdminEnergyCommunity,
|
||||
canActivate: [roleGuard(['ADMIN'])]
|
||||
},
|
||||
{
|
||||
path: 'tariffs',
|
||||
component: AdminTariffComponent,
|
||||
canActivate: [roleGuard(['ADMIN'])]
|
||||
},
|
||||
{
|
||||
path: 'my-tariffs',
|
||||
component: UserTariffComponent,
|
||||
canActivate: [roleGuard(['MEMBER'])]
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
component: ProfileComponent,
|
||||
|
||||
@ -12,5 +12,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{label: 'Mitgliedschaften', path: '/dashboard/admin-memberships', roles: ['ADMIN']},
|
||||
{label: 'Mein Profil', path: '/dashboard/profile', roles: ['ADMIN', 'MEMBER']},
|
||||
{label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']},
|
||||
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']}
|
||||
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']},
|
||||
{label: 'Tarife verwalten', path: '/dashboard/tariffs', roles: ['ADMIN']},
|
||||
{label: 'Meine Tarife', path: '/dashboard/my-tariffs', roles: ['MEMBER']}
|
||||
];
|
||||
|
||||
108
eeg_frontend/src/app/pages/admin-tariff/admin-tariff.html
Normal file
108
eeg_frontend/src/app/pages/admin-tariff/admin-tariff.html
Normal file
@ -0,0 +1,108 @@
|
||||
<div class="max-w-4xl mx-auto p-4 md:p-6">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-slate-800">Tarife verwalten</h2>
|
||||
<p class="text-sm text-slate-500 mt-1">Verwalte den Gemeinschaftstarif und Übersicht der Nutzertarife.</p>
|
||||
</div>
|
||||
|
||||
<!-- Community-Auswahl -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Energiegemeinschaft auswählen</label>
|
||||
<select (change)="onCommunityChange($any($event.target).value)"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white">
|
||||
<option value="">-- Bitte wählen --</option>
|
||||
@for (ec of communities(); track ec.id) {
|
||||
<option [value]="ec.id">{{ ec.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (selectedCommunityId()) {
|
||||
<!-- Gemeinschaftstarif -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<h3 class="text-lg font-bold text-slate-800 mb-4">Gemeinschaftstarif</h3>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="text-center py-4 text-slate-500">Lade Tarifdaten...</div>
|
||||
} @else {
|
||||
<form [formGroup]="tariffForm" (ngSubmit)="onSubmit()" class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Maximaler Preis (Cent/kWh)</label>
|
||||
<input type="number" formControlName="maxPricePerKwhCents" step="0.01"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
|
||||
@if (tariffForm.get('maxPricePerKwhCents')?.hasError('min')) {
|
||||
<p class="text-red-500 text-xs mt-1">Der Preis muss mindestens 0.01 Cent betragen.</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Aufschlag (%)</label>
|
||||
<input type="number" formControlName="surchargePercent" step="0.01"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
|
||||
@if (tariffForm.get('surchargePercent')?.hasError('max')) {
|
||||
<p class="text-red-500 text-xs mt-1">Der Aufschlag darf max. 100% betragen.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Gültig ab</label>
|
||||
<input type="date" formControlName="validFrom"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Gültig bis</label>
|
||||
<input type="date" formControlName="validTo"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" [disabled]="tariffForm.invalid"
|
||||
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">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Nutzertarife -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<h3 class="text-lg font-bold text-slate-800 mb-4">Nutzertarife in der Gemeinschaft</h3>
|
||||
|
||||
@if (isLoadingTariffs()) {
|
||||
<div class="text-center py-4 text-slate-500">Lade Nutzertarife...</div>
|
||||
} @else {
|
||||
<div class="overflow-x-auto ring-1 ring-slate-200 rounded-xl">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 text-sm text-slate-500 border-b border-slate-200">
|
||||
<th class="py-3 px-6 font-semibold">Quell-Zählpunkt</th>
|
||||
<th class="py-3 px-6 font-semibold">Ziel-Zählpunkt</th>
|
||||
<th class="py-3 px-6 font-semibold">Preis (Cent/kWh)</th>
|
||||
<th class="py-3 px-6 font-semibold">Gültig ab</th>
|
||||
<th class="py-3 px-6 font-semibold">Gültig bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-sm divide-y divide-slate-100">
|
||||
@for (ut of userTariffs(); track ut.id) {
|
||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||
<td class="py-3 px-6 font-mono text-xs text-slate-700">{{ ut.sourceMeteringPointId }}</td>
|
||||
<td class="py-3 px-6 font-mono text-xs text-slate-700">{{ ut.targetMeteringPointId }}</td>
|
||||
<td class="py-3 px-6 text-slate-600">{{ ut.pricePerKwhCents }}</td>
|
||||
<td class="py-3 px-6 text-slate-600">{{ ut.validFrom || '-' }}</td>
|
||||
<td class="py-3 px-6 text-slate-600">{{ ut.validTo || '-' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="py-8 text-center text-slate-500">
|
||||
Keine Nutzertarife vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
21
eeg_frontend/src/app/pages/admin-tariff/admin-tariff.spec.ts
Normal file
21
eeg_frontend/src/app/pages/admin-tariff/admin-tariff.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {AdminTariffComponent} from './admin-tariff';
|
||||
|
||||
describe('AdminTariffComponent', () => {
|
||||
let component: AdminTariffComponent;
|
||||
let fixture: ComponentFixture<AdminTariffComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AdminTariffComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AdminTariffComponent);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
120
eeg_frontend/src/app/pages/admin-tariff/admin-tariff.ts
Normal file
120
eeg_frontend/src/app/pages/admin-tariff/admin-tariff.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import {Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {EnergyCommunityAdminControllerService, EnergyCommunityDto, TariffControllerService, UserTariffControllerService, CommunityTariffRequest, CommunityTariffResponse, UserTariffResponse} from '../../api';
|
||||
import {ToastService} from '../../services/toast';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-tariff',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './admin-tariff.html',
|
||||
styleUrl: './admin-tariff.scss',
|
||||
})
|
||||
export class AdminTariffComponent implements OnInit, OnDestroy {
|
||||
private ecService = inject(EnergyCommunityAdminControllerService);
|
||||
private tariffService = inject(TariffControllerService);
|
||||
private userTariffService = inject(UserTariffControllerService);
|
||||
private toastService = inject(ToastService);
|
||||
private fb = inject(FormBuilder);
|
||||
|
||||
communities = signal<EnergyCommunityDto[]>([]);
|
||||
selectedCommunityId = signal<string>('');
|
||||
communityTariff = signal<CommunityTariffResponse | null>(null);
|
||||
userTariffs = signal<UserTariffResponse[]>([]);
|
||||
isLoading = signal(false);
|
||||
isLoadingTariffs = signal(false);
|
||||
|
||||
tariffForm = this.fb.group({
|
||||
maxPricePerKwhCents: [0, [Validators.required, Validators.min(0.01)]],
|
||||
surchargePercent: [0, [Validators.required, Validators.min(0), Validators.max(100)]],
|
||||
validFrom: [''],
|
||||
validTo: ['']
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.loadCommunities();
|
||||
}
|
||||
|
||||
ngOnDestroy() {}
|
||||
|
||||
loadCommunities() {
|
||||
this.ecService.getAllEnergyCommunities().subscribe({
|
||||
next: (data) => {
|
||||
this.communities.set(data);
|
||||
if (data.length > 0) {
|
||||
this.onCommunityChange(data[0].id!);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastService.error(err.error?.message || 'Fehler beim Laden der Energiegemeinschaften.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCommunityChange(communityId: string) {
|
||||
this.selectedCommunityId.set(communityId);
|
||||
this.communityTariff.set(null);
|
||||
this.userTariffs.set([]);
|
||||
|
||||
if (!communityId) return;
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.tariffService.getCommunityTariff(communityId).subscribe({
|
||||
next: (tariff) => {
|
||||
this.communityTariff.set(tariff);
|
||||
this.tariffForm.patchValue({
|
||||
maxPricePerKwhCents: tariff.maxPricePerKwhCents ?? 0,
|
||||
surchargePercent: tariff.surchargePercent ?? 0,
|
||||
validFrom: tariff.validFrom ?? '',
|
||||
validTo: tariff.validTo ?? ''
|
||||
});
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading.set(false);
|
||||
this.tariffForm.reset({maxPricePerKwhCents: 0, surchargePercent: 0, validFrom: '', validTo: ''});
|
||||
}
|
||||
});
|
||||
|
||||
this.loadUserTariffs(communityId);
|
||||
}
|
||||
|
||||
loadUserTariffs(communityId: string) {
|
||||
this.isLoadingTariffs.set(true);
|
||||
this.userTariffService.getUserTariffsForCommunity(communityId).subscribe({
|
||||
next: (tariffs) => {
|
||||
this.userTariffs.set(tariffs);
|
||||
this.isLoadingTariffs.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.userTariffs.set([]);
|
||||
this.isLoadingTariffs.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.tariffForm.invalid || !this.selectedCommunityId()) {
|
||||
this.tariffForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const request: CommunityTariffRequest = {
|
||||
maxPricePerKwhCents: this.tariffForm.value.maxPricePerKwhCents!,
|
||||
surchargePercent: this.tariffForm.value.surchargePercent!,
|
||||
validFrom: this.tariffForm.value.validFrom || undefined,
|
||||
validTo: this.tariffForm.value.validTo || undefined
|
||||
};
|
||||
|
||||
this.tariffService.createOrUpdateCommunityTariff(this.selectedCommunityId(), request).subscribe({
|
||||
next: (response) => {
|
||||
this.communityTariff.set(response);
|
||||
this.toastService.success('Gemeinschaftstarif erfolgreich gespeichert.');
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastService.error(err.error?.message || 'Fehler beim Speichern des Tarifs.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
160
eeg_frontend/src/app/pages/user-tariff/user-tariff.html
Normal file
160
eeg_frontend/src/app/pages/user-tariff/user-tariff.html
Normal file
@ -0,0 +1,160 @@
|
||||
<div class="max-w-4xl mx-auto p-4 md:p-6">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-slate-800">Meine Tarife</h2>
|
||||
<p class="text-sm text-slate-500 mt-1">Erstelle und verwalte Tarife für den Energieaustausch.</p>
|
||||
</div>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="text-center py-8 text-slate-500">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-slate-900 mb-2"></div>
|
||||
<div>Lade Daten...</div>
|
||||
</div>
|
||||
} @else if (memberships().length === 0) {
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
|
||||
<p class="text-yellow-800">Keine aktiven Mitgliedschaften vorhanden.</p>
|
||||
<p class="text-yellow-600 text-sm mt-2">Bitte tritt zuerst einer Energiegemeinschaft bei.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Community-Auswahl -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">Energiegemeinschaft auswählen</label>
|
||||
<select (change)="onCommunityChange($any($event.target).value)"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white">
|
||||
@for (m of memberships(); track m.energyCommunityId) {
|
||||
<option [value]="m.energyCommunityId" [selected]="m.energyCommunityId === selectedCommunityId()">
|
||||
{{ m.communityName }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (selectedCommunityId()) {
|
||||
<!-- Max-Preis Info -->
|
||||
@if (communityTariff()) {
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||
<span class="text-blue-600 text-lg">i</span>
|
||||
<span class="text-sm text-blue-800">
|
||||
Maximaler Preis: <strong>{{ communityTariff()!.maxPricePerKwhCents }} Cent/kWh</strong>
|
||||
@if (communityTariff()!.surchargePercent) {
|
||||
| Aufschlag: <strong>{{ communityTariff()!.surchargePercent }}%</strong>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Neuen Tarif erstellen / Bearbeiten -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<h3 class="text-lg font-bold text-slate-800 mb-4">
|
||||
{{ editingId() ? 'Tarif bearbeiten' : 'Neuen Tarif erstellen' }}
|
||||
</h3>
|
||||
<form [formGroup]="tariffForm" (ngSubmit)="onSubmit()" class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Quell-Zählpunkt (dein Zählpunkt)</label>
|
||||
<select formControlName="sourceMeteringPointId"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white">
|
||||
<option value="">-- Zählpunkt wählen --</option>
|
||||
@for (mp of myMeteringPoints; track mp.id) {
|
||||
<option [value]="mp.id">{{ mp.atNumber }} ({{ mp.type }})</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Ziel-Zählpunkt</label>
|
||||
<select formControlName="targetMeteringPointId"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white">
|
||||
<option value="">-- Zählpunkt wählen --</option>
|
||||
@for (mp of allCommunityMeteringPoints; track mp.id) {
|
||||
<option [value]="mp.id">{{ mp.atNumber }} ({{ mp.type }})</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Preis (Cent/kWh)</label>
|
||||
<input type="number" formControlName="pricePerKwhCents" step="0.01"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
|
||||
@if (tariffForm.get('pricePerKwhCents')?.hasError('min')) {
|
||||
<p class="text-red-500 text-xs mt-1">Der Preis muss mindestens 0.01 Cent betragen.</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Gültig ab</label>
|
||||
<input type="date" formControlName="validFrom"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Gültig bis</label>
|
||||
<input type="date" formControlName="validTo"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
@if (editingId()) {
|
||||
<button type="button" (click)="cancelEdit()"
|
||||
class="px-5 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-200 rounded-xl transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
}
|
||||
<button type="submit" [disabled]="tariffForm.invalid || isSubmitting()"
|
||||
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">
|
||||
{{ isSubmitting() ? 'Speichert...' : (editingId() ? 'Aktualisieren' : 'Erstellen') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Meine Tarife -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<h3 class="text-lg font-bold text-slate-800 mb-4">Meine Tarife</h3>
|
||||
|
||||
@if (isLoadingTariffs()) {
|
||||
<div class="text-center py-4 text-slate-500">Lade Tarife...</div>
|
||||
} @else {
|
||||
<div class="overflow-x-auto ring-1 ring-slate-200 rounded-xl">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 text-sm text-slate-500 border-b border-slate-200">
|
||||
<th class="py-3 px-6 font-semibold">Quell-Zählpunkt</th>
|
||||
<th class="py-3 px-6 font-semibold">Ziel-Zählpunkt</th>
|
||||
<th class="py-3 px-6 font-semibold">Preis (Cent/kWh)</th>
|
||||
<th class="py-3 px-6 font-semibold">Gültig ab</th>
|
||||
<th class="py-3 px-6 font-semibold">Gültig bis</th>
|
||||
<th class="py-3 px-6 font-semibold text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-sm divide-y divide-slate-100">
|
||||
@for (ut of userTariffs(); track ut.id) {
|
||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||
<td class="py-3 px-6 font-mono text-xs text-slate-700">{{ ut.sourceMeteringPointId }}</td>
|
||||
<td class="py-3 px-6 font-mono text-xs text-slate-700">{{ ut.targetMeteringPointId }}</td>
|
||||
<td class="py-3 px-6 text-slate-600">{{ ut.pricePerKwhCents }}</td>
|
||||
<td class="py-3 px-6 text-slate-600">{{ ut.validFrom || '-' }}</td>
|
||||
<td class="py-3 px-6 text-slate-600">{{ ut.validTo || '-' }}</td>
|
||||
<td class="py-3 px-6 text-right space-x-3">
|
||||
<button (click)="startEdit(ut)"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium transition-colors">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button (click)="onDelete(ut)"
|
||||
class="text-red-600 hover:text-red-800 font-medium transition-colors">
|
||||
Löschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="py-8 text-center text-slate-500">
|
||||
Keine Tarife vorhanden. Erstelle deinen ersten Tarif.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
21
eeg_frontend/src/app/pages/user-tariff/user-tariff.spec.ts
Normal file
21
eeg_frontend/src/app/pages/user-tariff/user-tariff.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {UserTariffComponent} from './user-tariff';
|
||||
|
||||
describe('UserTariffComponent', () => {
|
||||
let component: UserTariffComponent;
|
||||
let fixture: ComponentFixture<UserTariffComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UserTariffComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UserTariffComponent);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
200
eeg_frontend/src/app/pages/user-tariff/user-tariff.ts
Normal file
200
eeg_frontend/src/app/pages/user-tariff/user-tariff.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import {Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {UserTariffControllerService, TariffControllerService, UserTariffRequest, UserTariffResponse, CommunityTariffResponse} from '../../api';
|
||||
import {MeteringPointService, MeteringPoint} from '../../services/metering-point';
|
||||
import {MembershipService, MembershipResponse} from '../../services/membership';
|
||||
import {ToastService} from '../../services/toast';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-tariff',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './user-tariff.html',
|
||||
styleUrl: './user-tariff.scss',
|
||||
})
|
||||
export class UserTariffComponent implements OnInit, OnDestroy {
|
||||
private userTariffService = inject(UserTariffControllerService);
|
||||
private tariffService = inject(TariffControllerService);
|
||||
private meteringPointService = inject(MeteringPointService);
|
||||
private membershipService = inject(MembershipService);
|
||||
private toastService = inject(ToastService);
|
||||
private fb = inject(FormBuilder);
|
||||
|
||||
memberships = signal<MembershipResponse[]>([]);
|
||||
meteringPoints = signal<MeteringPoint[]>([]);
|
||||
userTariffs = signal<UserTariffResponse[]>([]);
|
||||
communityTariff = signal<CommunityTariffResponse | null>(null);
|
||||
selectedCommunityId = signal<string>('');
|
||||
isLoading = signal(true);
|
||||
isLoadingTariffs = signal(false);
|
||||
isSubmitting = signal(false);
|
||||
editingId = signal<string | null>(null);
|
||||
|
||||
tariffForm = this.fb.group({
|
||||
energyCommunityId: ['', Validators.required],
|
||||
sourceMeteringPointId: ['', Validators.required],
|
||||
targetMeteringPointId: ['', Validators.required],
|
||||
pricePerKwhCents: [0, [Validators.required, Validators.min(0.01)]],
|
||||
validFrom: [''],
|
||||
validTo: ['']
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
ngOnDestroy() {}
|
||||
|
||||
loadData() {
|
||||
this.isLoading.set(true);
|
||||
|
||||
this.membershipService.getOwnMemberships().subscribe({
|
||||
next: (memberships) => {
|
||||
const active = memberships.filter(m => m.status === 'ACTIVE');
|
||||
this.memberships.set(active);
|
||||
|
||||
if (active.length > 0 && !this.selectedCommunityId()) {
|
||||
this.onCommunityChange(active[0].energyCommunityId);
|
||||
}
|
||||
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading.set(false);
|
||||
this.toastService.error('Fehler beim Laden der Mitgliedschaften.');
|
||||
}
|
||||
});
|
||||
|
||||
this.meteringPointService.getMyMeteringPoints().subscribe({
|
||||
next: (points) => {
|
||||
this.meteringPoints.set(points.filter(p => p.makoState === 'ACTIVE'));
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
onCommunityChange(communityId: string) {
|
||||
this.selectedCommunityId.set(communityId);
|
||||
this.tariffForm.patchValue({energyCommunityId: communityId});
|
||||
this.userTariffs.set([]);
|
||||
this.communityTariff.set(null);
|
||||
this.editingId.set(null);
|
||||
|
||||
if (!communityId) return;
|
||||
|
||||
this.tariffService.getCommunityTariff(communityId).subscribe({
|
||||
next: (tariff) => this.communityTariff.set(tariff),
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
this.loadUserTariffs(communityId);
|
||||
}
|
||||
|
||||
loadUserTariffs(communityId: string) {
|
||||
this.isLoadingTariffs.set(true);
|
||||
this.userTariffService.getUserTariffsForCommunity(communityId).subscribe({
|
||||
next: (tariffs) => {
|
||||
this.userTariffs.set(tariffs);
|
||||
this.isLoadingTariffs.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.userTariffs.set([]);
|
||||
this.isLoadingTariffs.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get myMeteringPoints(): MeteringPoint[] {
|
||||
return this.meteringPoints();
|
||||
}
|
||||
|
||||
get allCommunityMeteringPoints(): MeteringPoint[] {
|
||||
return this.meteringPoints();
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.tariffForm.invalid || !this.selectedCommunityId()) {
|
||||
this.tariffForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPrice = this.communityTariff()?.maxPricePerKwhCents ?? Infinity;
|
||||
const price = this.tariffForm.value.pricePerKwhCents!;
|
||||
if (price > maxPrice) {
|
||||
this.toastService.error(`Der Preis darf den Maximalpreis von ${maxPrice} Cent/kWh nicht überschreiten.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const request: UserTariffRequest = {
|
||||
energyCommunityId: this.tariffForm.value.energyCommunityId!,
|
||||
sourceMeteringPointId: this.tariffForm.value.sourceMeteringPointId!,
|
||||
targetMeteringPointId: this.tariffForm.value.targetMeteringPointId!,
|
||||
pricePerKwhCents: this.tariffForm.value.pricePerKwhCents!,
|
||||
validFrom: this.tariffForm.value.validFrom || undefined,
|
||||
validTo: this.tariffForm.value.validTo || undefined
|
||||
};
|
||||
|
||||
this.isSubmitting.set(true);
|
||||
|
||||
const editId = this.editingId();
|
||||
const obs = editId
|
||||
? this.userTariffService.updateUserTariff(editId, request)
|
||||
: this.userTariffService.createUserTariff(request);
|
||||
|
||||
obs.subscribe({
|
||||
next: () => {
|
||||
this.toastService.success(editId ? 'Tarif aktualisiert.' : 'Tarif erfolgreich erstellt.');
|
||||
this.resetForm();
|
||||
this.loadUserTariffs(this.selectedCommunityId());
|
||||
this.isSubmitting.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.isSubmitting.set(false);
|
||||
this.toastService.error(err.error?.message || 'Fehler beim Speichern des Tarifs.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startEdit(tariff: UserTariffResponse) {
|
||||
this.editingId.set(tariff.id!);
|
||||
this.tariffForm.patchValue({
|
||||
energyCommunityId: tariff.energyCommunityId!,
|
||||
sourceMeteringPointId: tariff.sourceMeteringPointId!,
|
||||
targetMeteringPointId: tariff.targetMeteringPointId!,
|
||||
pricePerKwhCents: tariff.pricePerKwhCents!,
|
||||
validFrom: tariff.validFrom ?? '',
|
||||
validTo: tariff.validTo ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.resetForm();
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.editingId.set(null);
|
||||
this.tariffForm.reset({
|
||||
energyCommunityId: this.selectedCommunityId(),
|
||||
sourceMeteringPointId: '',
|
||||
targetMeteringPointId: '',
|
||||
pricePerKwhCents: 0,
|
||||
validFrom: '',
|
||||
validTo: ''
|
||||
});
|
||||
}
|
||||
|
||||
onDelete(tariff: UserTariffResponse) {
|
||||
if (!confirm('Möchtest du diesen Tarif wirklich löschen?')) return;
|
||||
|
||||
this.userTariffService.deleteUserTariff(tariff.id!, tariff.sourceMeteringPointId!).subscribe({
|
||||
next: () => {
|
||||
this.toastService.success('Tarif erfolgreich gelöscht.');
|
||||
this.loadUserTariffs(this.selectedCommunityId());
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastService.error(err.error?.message || 'Fehler beim Löschen des Tarifs.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user