feat(sprint-3): Phase 6 — prevention officer capability
- PreventionOfficerService: limit enforcement, under-21 monitoring, monthly distribution tracking
- PUT /api/v1/staff/{id}/prevention-officer: assign/revoke with club limit check (409 on exceed)
- GET /api/v1/members/under-21: list under-21 members with quota data (prevention officer access)
- GET /api/v1/members/{id}/prevention-data: member prevention details (quota, distributions)
- PreventionOfficerLimitExceededException mapped to 409 in GlobalExceptionHandler
- StaffResponse extended with preventionOfficer field
- PreventionOfficerServiceTest: 10 unit tests covering assignment, revocation, limits, age calc
- MemberRepository.findByTenantIdAndUnder21True added
This commit is contained in:
@@ -3,9 +3,12 @@ package de.cannamanage.api.controller;
|
|||||||
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
||||||
import de.cannamanage.api.dto.member.MemberResponse;
|
import de.cannamanage.api.dto.member.MemberResponse;
|
||||||
import de.cannamanage.api.dto.member.UpdateMemberRequest;
|
import de.cannamanage.api.dto.member.UpdateMemberRequest;
|
||||||
|
import de.cannamanage.api.dto.prevention.PreventionDataResponse;
|
||||||
|
import de.cannamanage.api.dto.prevention.Under21MemberResponse;
|
||||||
import de.cannamanage.domain.entity.Member;
|
import de.cannamanage.domain.entity.Member;
|
||||||
import de.cannamanage.domain.entity.TenantContext;
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
import de.cannamanage.domain.enums.MemberStatus;
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.PreventionOfficerService;
|
||||||
import de.cannamanage.service.repository.MemberRepository;
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -17,6 +20,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.Period;
|
import java.time.Period;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -29,6 +33,7 @@ import java.util.UUID;
|
|||||||
public class MemberController {
|
public class MemberController {
|
||||||
|
|
||||||
private final MemberRepository memberRepository;
|
private final MemberRepository memberRepository;
|
||||||
|
private final PreventionOfficerService preventionOfficerService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
|
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
|
||||||
@@ -89,6 +94,57 @@ public class MemberController {
|
|||||||
return ResponseEntity.ok(toResponse(saved));
|
return ResponseEntity.ok(toResponse(saved));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/under-21")
|
||||||
|
@Operation(summary = "List under-21 members", description = "Returns all under-21 members with current month distribution data. Prevention officer or ADMIN access required.")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
|
||||||
|
public ResponseEntity<List<Under21MemberResponse>> getUnder21Members() {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
List<Member> under21Members = preventionOfficerService.getUnder21Members(tenantId);
|
||||||
|
|
||||||
|
List<Under21MemberResponse> response = under21Members.stream()
|
||||||
|
.map(m -> {
|
||||||
|
int age = preventionOfficerService.calculateAge(m.getDateOfBirth());
|
||||||
|
long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, m.getId());
|
||||||
|
BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, m.getId());
|
||||||
|
BigDecimal limit = preventionOfficerService.getMonthlyLimit(m);
|
||||||
|
BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO);
|
||||||
|
String quotaStatus = remaining.compareTo(BigDecimal.ZERO) > 0 ? "OK" : "EXHAUSTED";
|
||||||
|
return new Under21MemberResponse(
|
||||||
|
m.getId(), m.getFirstName(), m.getLastName(),
|
||||||
|
age, m.getDateOfBirth(), distCount,
|
||||||
|
gramsUsed, limit, quotaStatus
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/prevention-data")
|
||||||
|
@Operation(summary = "Get prevention data for a member", description = "Returns prevention-relevant data for a specific member. Prevention officer or ADMIN access required.")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
|
||||||
|
public ResponseEntity<PreventionDataResponse> getPreventionData(@PathVariable UUID id) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
Member member = memberRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
|
||||||
|
|
||||||
|
int age = preventionOfficerService.calculateAge(member.getDateOfBirth());
|
||||||
|
long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, member.getId());
|
||||||
|
BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, member.getId());
|
||||||
|
BigDecimal limit = preventionOfficerService.getMonthlyLimit(member);
|
||||||
|
BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new PreventionDataResponse(
|
||||||
|
member.getId(),
|
||||||
|
member.getFirstName() + " " + member.getLastName(),
|
||||||
|
member.isUnder21(),
|
||||||
|
age,
|
||||||
|
distCount,
|
||||||
|
gramsUsed,
|
||||||
|
limit,
|
||||||
|
remaining
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isUnder21(LocalDate dateOfBirth) {
|
private boolean isUnder21(LocalDate dateOfBirth) {
|
||||||
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
|
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.cannamanage.api.controller;
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.prevention.PreventionOfficerRequest;
|
||||||
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
||||||
import de.cannamanage.api.dto.staff.StaffResponse;
|
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||||
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||||
@@ -7,6 +8,7 @@ import de.cannamanage.domain.entity.StaffAccount;
|
|||||||
import de.cannamanage.domain.entity.TenantContext;
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
import de.cannamanage.domain.entity.User;
|
import de.cannamanage.domain.entity.User;
|
||||||
import de.cannamanage.domain.enums.StaffPermission;
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
import de.cannamanage.service.PreventionOfficerService;
|
||||||
import de.cannamanage.service.StaffService;
|
import de.cannamanage.service.StaffService;
|
||||||
import de.cannamanage.service.StaffTemplates;
|
import de.cannamanage.service.StaffTemplates;
|
||||||
import de.cannamanage.service.repository.UserRepository;
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
@@ -31,6 +33,7 @@ import java.util.UUID;
|
|||||||
public class StaffController {
|
public class StaffController {
|
||||||
|
|
||||||
private final StaffService staffService;
|
private final StaffService staffService;
|
||||||
|
private final PreventionOfficerService preventionOfficerService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -103,6 +106,19 @@ public class StaffController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/prevention-officer")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Operation(summary = "Assign or revoke prevention officer status",
|
||||||
|
description = "Sets prevention officer flag on a staff member. Enforces club.maxPreventionOfficers limit on assign.")
|
||||||
|
public ResponseEntity<StaffResponse> setPreventionOfficer(@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody PreventionOfficerRequest request) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
StaffAccount staff = preventionOfficerService.setPreventionOfficer(tenantId, id, request.preventionOfficer());
|
||||||
|
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||||
|
String email = user != null ? user.getEmail() : "unknown";
|
||||||
|
return ResponseEntity.ok(StaffResponse.from(staff, email));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/templates")
|
@GetMapping("/templates")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@Operation(summary = "List available permission templates")
|
@Operation(summary = "List available permission templates")
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.api.dto.prevention;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record PreventionDataResponse(
|
||||||
|
UUID memberId,
|
||||||
|
String name,
|
||||||
|
boolean isUnder21,
|
||||||
|
int age,
|
||||||
|
long currentMonthDistributions,
|
||||||
|
BigDecimal gramsUsedThisMonth,
|
||||||
|
BigDecimal monthlyLimit,
|
||||||
|
BigDecimal quotaRemaining
|
||||||
|
) {}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package de.cannamanage.api.dto.prevention;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record PreventionOfficerRequest(
|
||||||
|
@NotNull Boolean preventionOfficer
|
||||||
|
) {}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package de.cannamanage.api.dto.prevention;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record Under21MemberResponse(
|
||||||
|
UUID id,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
int age,
|
||||||
|
LocalDate dateOfBirth,
|
||||||
|
long totalDistributionsThisMonth,
|
||||||
|
BigDecimal gramsUsedThisMonth,
|
||||||
|
BigDecimal monthlyLimit,
|
||||||
|
String quotaStatus
|
||||||
|
) {}
|
||||||
@@ -19,6 +19,7 @@ public record StaffResponse(
|
|||||||
Set<StaffPermission> permissions,
|
Set<StaffPermission> permissions,
|
||||||
String templateName,
|
String templateName,
|
||||||
boolean active,
|
boolean active,
|
||||||
|
boolean preventionOfficer,
|
||||||
Instant createdAt
|
Instant createdAt
|
||||||
) {
|
) {
|
||||||
public static StaffResponse from(StaffAccount staff, User user) {
|
public static StaffResponse from(StaffAccount staff, User user) {
|
||||||
@@ -30,6 +31,7 @@ public record StaffResponse(
|
|||||||
staff.getGrantedPermissions(),
|
staff.getGrantedPermissions(),
|
||||||
null, // templateName not stored; permissions are expanded
|
null, // templateName not stored; permissions are expanded
|
||||||
staff.isActive(),
|
staff.isActive(),
|
||||||
|
staff.isPreventionOfficer(),
|
||||||
staff.getCreatedAt()
|
staff.getCreatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@ public record StaffResponse(
|
|||||||
staff.getGrantedPermissions(),
|
staff.getGrantedPermissions(),
|
||||||
null,
|
null,
|
||||||
staff.isActive(),
|
staff.isActive(),
|
||||||
|
staff.isPreventionOfficer(),
|
||||||
staff.getCreatedAt()
|
staff.getCreatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -3,6 +3,7 @@ package de.cannamanage.api.exception;
|
|||||||
import de.cannamanage.api.service.AuthService;
|
import de.cannamanage.api.service.AuthService;
|
||||||
import de.cannamanage.service.exception.BatchNotFoundException;
|
import de.cannamanage.service.exception.BatchNotFoundException;
|
||||||
import de.cannamanage.service.exception.MemberNotFoundException;
|
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||||
|
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
|
||||||
import de.cannamanage.service.exception.QuotaExceededException;
|
import de.cannamanage.service.exception.QuotaExceededException;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -108,6 +109,18 @@ public class GlobalExceptionHandler {
|
|||||||
return problem;
|
return problem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(PreventionOfficerLimitExceededException.class)
|
||||||
|
public ProblemDetail handlePreventionOfficerLimitExceeded(PreventionOfficerLimitExceededException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.CONFLICT, ex.getMessage());
|
||||||
|
problem.setTitle("Prevention Officer Limit Exceeded");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:PREVENTION_OFFICER_LIMIT_EXCEEDED"));
|
||||||
|
problem.setProperty("code", "PREVENTION_OFFICER_LIMIT_EXCEEDED");
|
||||||
|
problem.setProperty("maxAllowed", ex.getMaxAllowed());
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(ResponseStatusException.class)
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
||||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import de.cannamanage.domain.entity.TenantContext;
|
|||||||
import de.cannamanage.domain.entity.User;
|
import de.cannamanage.domain.entity.User;
|
||||||
import de.cannamanage.domain.enums.StaffPermission;
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
import de.cannamanage.domain.enums.UserRole;
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import de.cannamanage.service.PreventionOfficerService;
|
||||||
import de.cannamanage.service.StaffService;
|
import de.cannamanage.service.StaffService;
|
||||||
import de.cannamanage.service.TokenRevocationService;
|
import de.cannamanage.service.TokenRevocationService;
|
||||||
import de.cannamanage.service.repository.UserRepository;
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
@@ -40,6 +41,7 @@ class StaffControllerTest {
|
|||||||
@Autowired private ObjectMapper objectMapper;
|
@Autowired private ObjectMapper objectMapper;
|
||||||
|
|
||||||
@MockBean private StaffService staffService;
|
@MockBean private StaffService staffService;
|
||||||
|
@MockBean private PreventionOfficerService preventionOfficerService;
|
||||||
@MockBean private UserRepository userRepository;
|
@MockBean private UserRepository userRepository;
|
||||||
@MockBean private JwtService jwtService;
|
@MockBean private JwtService jwtService;
|
||||||
@MockBean private JwtAuthFilter jwtAuthFilter;
|
@MockBean private JwtAuthFilter jwtAuthFilter;
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.constants.ComplianceConstants;
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.StaffAccount;
|
||||||
|
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
|
||||||
|
import de.cannamanage.service.repository.ClubRepository;
|
||||||
|
import de.cannamanage.service.repository.DistributionRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.Period;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for prevention officer assignment and under-21 member monitoring.
|
||||||
|
* Enforces the configurable limit per club and provides prevention-relevant data.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PreventionOfficerService {
|
||||||
|
|
||||||
|
private final StaffAccountRepository staffAccountRepository;
|
||||||
|
private final ClubRepository clubRepository;
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final DistributionRepository distributionRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign or revoke prevention officer status on a staff account.
|
||||||
|
* On assign: enforces club.maxPreventionOfficers limit.
|
||||||
|
* On revoke: always succeeds.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public StaffAccount setPreventionOfficer(UUID tenantId, UUID staffId, boolean assign) {
|
||||||
|
StaffAccount staff = staffAccountRepository.findById(staffId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Staff account not found"));
|
||||||
|
|
||||||
|
if (!staff.getTenantId().equals(tenantId)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Staff account not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assign) {
|
||||||
|
// Check limit before assigning
|
||||||
|
long currentCount = staffAccountRepository.countByTenantIdAndPreventionOfficerTrueAndActiveTrue(tenantId);
|
||||||
|
Club club = clubRepository.findById(tenantId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Club not found"));
|
||||||
|
|
||||||
|
if (currentCount >= club.getMaxPreventionOfficers()) {
|
||||||
|
throw new PreventionOfficerLimitExceededException(club.getMaxPreventionOfficers());
|
||||||
|
}
|
||||||
|
|
||||||
|
staff.setPreventionOfficer(true);
|
||||||
|
log.info("Prevention officer assigned: staffId={}, tenantId={}", staffId, tenantId);
|
||||||
|
} else {
|
||||||
|
staff.setPreventionOfficer(false);
|
||||||
|
log.info("Prevention officer revoked: staffId={}, tenantId={}", staffId, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return staffAccountRepository.save(staff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all under-21 members for the tenant with their current month distribution data.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Member> getUnder21Members(UUID tenantId) {
|
||||||
|
return memberRepository.findByTenantIdAndUnder21True(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of distributions for a member in the current month.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long countCurrentMonthDistributions(UUID tenantId, UUID memberId) {
|
||||||
|
Instant monthStart = getMonthStart();
|
||||||
|
Instant now = Instant.now();
|
||||||
|
List<Distribution> distributions = distributionRepository
|
||||||
|
.findByTenantIdAndDistributedAtBetween(tenantId, monthStart, now);
|
||||||
|
return distributions.stream()
|
||||||
|
.filter(d -> d.getMemberId().equals(memberId))
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total grams distributed to a member in the current month.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public BigDecimal sumCurrentMonthGrams(UUID tenantId, UUID memberId) {
|
||||||
|
Instant monthStart = getMonthStart();
|
||||||
|
Instant now = Instant.now();
|
||||||
|
List<Distribution> distributions = distributionRepository
|
||||||
|
.findByTenantIdAndDistributedAtBetween(tenantId, monthStart, now);
|
||||||
|
return distributions.stream()
|
||||||
|
.filter(d -> d.getMemberId().equals(memberId))
|
||||||
|
.map(Distribution::getQuantityGrams)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the monthly limit for a member based on their age.
|
||||||
|
*/
|
||||||
|
public BigDecimal getMonthlyLimit(Member member) {
|
||||||
|
if (member.isUnder21()) {
|
||||||
|
return ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS;
|
||||||
|
}
|
||||||
|
return ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the age of a member.
|
||||||
|
*/
|
||||||
|
public int calculateAge(LocalDate dateOfBirth) {
|
||||||
|
return Period.between(dateOfBirth, LocalDate.now()).getYears();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant getMonthStart() {
|
||||||
|
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Europe/Berlin"));
|
||||||
|
ZonedDateTime monthStart = now.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||||
|
return monthStart.toInstant();
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package de.cannamanage.service.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when attempting to assign more prevention officers than the club limit allows.
|
||||||
|
* Maps to HTTP 409 Conflict.
|
||||||
|
*/
|
||||||
|
public class PreventionOfficerLimitExceededException extends RuntimeException {
|
||||||
|
|
||||||
|
private final int maxAllowed;
|
||||||
|
|
||||||
|
public PreventionOfficerLimitExceededException(int maxAllowed) {
|
||||||
|
super("Prevention officer limit exceeded. Maximum allowed: " + maxAllowed);
|
||||||
|
this.maxAllowed = maxAllowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMaxAllowed() {
|
||||||
|
return maxAllowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -24,4 +24,9 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
|
|||||||
* Find all members for a tenant (all statuses).
|
* Find all members for a tenant (all statuses).
|
||||||
*/
|
*/
|
||||||
List<Member> findByTenantId(UUID tenantId);
|
List<Member> findByTenantId(UUID tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all under-21 members for a tenant.
|
||||||
|
*/
|
||||||
|
List<Member> findByTenantIdAndUnder21True(UUID tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.StaffAccount;
|
||||||
|
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
|
||||||
|
import de.cannamanage.service.repository.ClubRepository;
|
||||||
|
import de.cannamanage.service.repository.DistributionRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PreventionOfficerServiceTest {
|
||||||
|
|
||||||
|
@Mock private StaffAccountRepository staffAccountRepository;
|
||||||
|
@Mock private ClubRepository clubRepository;
|
||||||
|
@Mock private MemberRepository memberRepository;
|
||||||
|
@Mock private DistributionRepository distributionRepository;
|
||||||
|
|
||||||
|
@InjectMocks private PreventionOfficerService service;
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private UUID staffId;
|
||||||
|
private StaffAccount staffAccount;
|
||||||
|
private Club club;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = UUID.randomUUID();
|
||||||
|
staffId = UUID.randomUUID();
|
||||||
|
|
||||||
|
staffAccount = new StaffAccount();
|
||||||
|
staffAccount.setId(staffId);
|
||||||
|
staffAccount.setTenantId(tenantId);
|
||||||
|
staffAccount.setUserId(UUID.randomUUID());
|
||||||
|
staffAccount.setDisplayName("Test Staff");
|
||||||
|
staffAccount.setActive(true);
|
||||||
|
staffAccount.setPreventionOfficer(false);
|
||||||
|
|
||||||
|
club = new Club();
|
||||||
|
club.setId(tenantId);
|
||||||
|
club.setTenantId(tenantId);
|
||||||
|
club.setMaxPreventionOfficers(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Assignment Tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void assignPreventionOfficer_underLimit_succeeds() {
|
||||||
|
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount));
|
||||||
|
when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrueAndActiveTrue(tenantId)).thenReturn(1L);
|
||||||
|
when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club));
|
||||||
|
when(staffAccountRepository.save(any())).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
StaffAccount result = service.setPreventionOfficer(tenantId, staffId, true);
|
||||||
|
|
||||||
|
assertThat(result.isPreventionOfficer()).isTrue();
|
||||||
|
verify(staffAccountRepository).save(staffAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void assignPreventionOfficer_atLimit_throws409() {
|
||||||
|
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount));
|
||||||
|
when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrueAndActiveTrue(tenantId)).thenReturn(2L);
|
||||||
|
when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.setPreventionOfficer(tenantId, staffId, true))
|
||||||
|
.isInstanceOf(PreventionOfficerLimitExceededException.class)
|
||||||
|
.hasMessageContaining("Maximum allowed: 2");
|
||||||
|
|
||||||
|
verify(staffAccountRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokePreventionOfficer_alwaysSucceeds() {
|
||||||
|
staffAccount.setPreventionOfficer(true);
|
||||||
|
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount));
|
||||||
|
when(staffAccountRepository.save(any())).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
StaffAccount result = service.setPreventionOfficer(tenantId, staffId, false);
|
||||||
|
|
||||||
|
assertThat(result.isPreventionOfficer()).isFalse();
|
||||||
|
verify(staffAccountRepository).save(staffAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void assignPreventionOfficer_wrongTenant_throws404() {
|
||||||
|
UUID otherTenant = UUID.randomUUID();
|
||||||
|
staffAccount.setTenantId(otherTenant);
|
||||||
|
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.setPreventionOfficer(tenantId, staffId, true))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.hasMessageContaining("not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void assignPreventionOfficer_notFound_throws404() {
|
||||||
|
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.setPreventionOfficer(tenantId, staffId, true))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.hasMessageContaining("not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Under-21 Members ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUnder21Members_returnsList() {
|
||||||
|
Member member = new Member();
|
||||||
|
member.setId(UUID.randomUUID());
|
||||||
|
member.setTenantId(tenantId);
|
||||||
|
member.setUnder21(true);
|
||||||
|
member.setFirstName("Young");
|
||||||
|
member.setLastName("Member");
|
||||||
|
member.setDateOfBirth(LocalDate.now().minusYears(20));
|
||||||
|
|
||||||
|
when(memberRepository.findByTenantIdAndUnder21True(tenantId)).thenReturn(List.of(member));
|
||||||
|
|
||||||
|
List<Member> result = service.getUnder21Members(tenantId);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).isUnder21()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Distribution Counting ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countCurrentMonthDistributions_countsCorrectly() {
|
||||||
|
UUID memberId = UUID.randomUUID();
|
||||||
|
Distribution d1 = new Distribution();
|
||||||
|
d1.setMemberId(memberId);
|
||||||
|
d1.setDistributedAt(Instant.now());
|
||||||
|
d1.setQuantityGrams(BigDecimal.valueOf(5));
|
||||||
|
|
||||||
|
Distribution d2 = new Distribution();
|
||||||
|
d2.setMemberId(memberId);
|
||||||
|
d2.setDistributedAt(Instant.now());
|
||||||
|
d2.setQuantityGrams(BigDecimal.valueOf(10));
|
||||||
|
|
||||||
|
Distribution otherMemberDist = new Distribution();
|
||||||
|
otherMemberDist.setMemberId(UUID.randomUUID());
|
||||||
|
otherMemberDist.setDistributedAt(Instant.now());
|
||||||
|
otherMemberDist.setQuantityGrams(BigDecimal.valueOf(7));
|
||||||
|
|
||||||
|
when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(tenantId), any(), any()))
|
||||||
|
.thenReturn(List.of(d1, d2, otherMemberDist));
|
||||||
|
|
||||||
|
long count = service.countCurrentMonthDistributions(tenantId, memberId);
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sumCurrentMonthGrams_sumsCorrectly() {
|
||||||
|
UUID memberId = UUID.randomUUID();
|
||||||
|
Distribution d1 = new Distribution();
|
||||||
|
d1.setMemberId(memberId);
|
||||||
|
d1.setQuantityGrams(BigDecimal.valueOf(5));
|
||||||
|
d1.setDistributedAt(Instant.now());
|
||||||
|
|
||||||
|
Distribution d2 = new Distribution();
|
||||||
|
d2.setMemberId(memberId);
|
||||||
|
d2.setQuantityGrams(BigDecimal.valueOf(10));
|
||||||
|
d2.setDistributedAt(Instant.now());
|
||||||
|
|
||||||
|
when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(tenantId), any(), any()))
|
||||||
|
.thenReturn(List.of(d1, d2));
|
||||||
|
|
||||||
|
BigDecimal grams = service.sumCurrentMonthGrams(tenantId, memberId);
|
||||||
|
|
||||||
|
assertThat(grams).isEqualByComparingTo(BigDecimal.valueOf(15));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Monthly Limit ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getMonthlyLimit_under21_returns30() {
|
||||||
|
Member member = new Member();
|
||||||
|
member.setUnder21(true);
|
||||||
|
|
||||||
|
BigDecimal limit = service.getMonthlyLimit(member);
|
||||||
|
|
||||||
|
assertThat(limit).isEqualByComparingTo(BigDecimal.valueOf(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getMonthlyLimit_adult_returns50() {
|
||||||
|
Member member = new Member();
|
||||||
|
member.setUnder21(false);
|
||||||
|
|
||||||
|
BigDecimal limit = service.getMonthlyLimit(member);
|
||||||
|
|
||||||
|
assertThat(limit).isEqualByComparingTo(BigDecimal.valueOf(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Age Calculation ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculateAge_returnsCorrectAge() {
|
||||||
|
LocalDate dob = LocalDate.now().minusYears(20).minusDays(10);
|
||||||
|
int age = service.calculateAge(dob);
|
||||||
|
assertThat(age).isEqualTo(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculateAge_birthday_today() {
|
||||||
|
LocalDate dob = LocalDate.now().minusYears(21);
|
||||||
|
int age = service.calculateAge(dob);
|
||||||
|
assertThat(age).isEqualTo(21);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user