feat(sprint-3): Phase 4 — report controller + PDF/CSV generation

- Add report data models (MonthlyReport, MemberListReport, RecallReport)
- Implement ReportService with monthly aggregation, member list, recall batch tracing
- Add PdfReportGenerator using OpenPDF with minimal club branding
- Add PdfFooterHandler for timestamp + page numbers on every page
- Add CsvReportGenerator with UTF-8 BOM for Excel compatibility
- Create ReportController with 3 endpoints (monthly, members, recall)
  supporting JSON/PDF/CSV format negotiation via ?format= param
- Add DTO records (MonthlyReportResponse, MemberListResponse, RecallReportResponse)
- Extend DistributionRepository + MemberRepository with report queries
- Update Commons CSV from 1.11.0 to 1.12.0
- 10 unit tests (ReportServiceTest: 6, PdfReportGeneratorTest: 4) all passing

Endpoints:
  GET /api/v1/reports/monthly?month=YYYY-MM&format=json|pdf|csv
  GET /api/v1/reports/members?format=json|pdf|csv&status=ACTIVE
  GET /api/v1/reports/recall/{batchId}?format=json|pdf
This commit is contained in:
Patrick Plate
2026-06-12 09:38:57 +02:00
parent a267a90542
commit 64927a3244
16 changed files with 1497 additions and 1 deletions
@@ -0,0 +1,199 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.report.MemberListResponse;
import de.cannamanage.api.dto.report.MonthlyReportResponse;
import de.cannamanage.api.dto.report.RecallReportResponse;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.CsvReportGenerator;
import de.cannamanage.service.PdfReportGenerator;
import de.cannamanage.service.ReportService;
import de.cannamanage.service.model.report.MemberListReport;
import de.cannamanage.service.model.report.MonthlyReport;
import de.cannamanage.service.model.report.RecallReport;
import de.cannamanage.service.repository.ClubRepository;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.YearMonth;
import java.util.UUID;
/**
* REST controller for compliance and operational reports.
* Supports JSON, PDF, and CSV output formats.
*/
@RestController
@RequestMapping("/api/v1/reports")
public class ReportController {
private final ReportService reportService;
private final PdfReportGenerator pdfGenerator;
private final CsvReportGenerator csvGenerator;
private final ClubRepository clubRepository;
public ReportController(ReportService reportService,
PdfReportGenerator pdfGenerator,
CsvReportGenerator csvGenerator,
ClubRepository clubRepository) {
this.reportService = reportService;
this.pdfGenerator = pdfGenerator;
this.csvGenerator = csvGenerator;
this.clubRepository = clubRepository;
}
/**
* Monthly distribution report.
* GET /api/v1/reports/monthly?month=2026-03&format=json|pdf|csv
*/
@GetMapping("/monthly")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
public ResponseEntity<?> monthlyReport(
@RequestParam String month,
@RequestParam(defaultValue = "json") String format) {
UUID tenantId = TenantContext.getCurrentTenant();
YearMonth ym = YearMonth.parse(month);
MonthlyReport report = reportService.generateMonthlyReport(tenantId, ym);
return switch (format.toLowerCase()) {
case "pdf" -> {
Club club = getClub(tenantId);
byte[] pdf = pdfGenerator.renderMonthlyReport(report, club);
yield ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"monatsbericht-" + month + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
case "csv" -> {
byte[] csv = csvGenerator.renderMonthlyReport(report);
yield ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"monatsbericht-" + month + ".csv\"")
.contentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8))
.body(csv);
}
default -> ResponseEntity.ok(toMonthlyResponse(report));
};
}
/**
* Member list report.
* GET /api/v1/reports/members?format=json|pdf|csv&status=ACTIVE
*/
@GetMapping("/members")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
public ResponseEntity<?> memberListReport(
@RequestParam(defaultValue = "json") String format,
@RequestParam(required = false) MemberStatus status) {
UUID tenantId = TenantContext.getCurrentTenant();
MemberListReport report = reportService.generateMemberListReport(tenantId, status);
return switch (format.toLowerCase()) {
case "pdf" -> {
Club club = getClub(tenantId);
byte[] pdf = pdfGenerator.renderMemberList(report, club);
yield ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"mitgliederliste.pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
case "csv" -> {
byte[] csv = csvGenerator.renderMemberList(report);
yield ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"mitgliederliste.csv\"")
.contentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8))
.body(csv);
}
default -> ResponseEntity.ok(toMemberListResponse(report));
};
}
/**
* Recall/batch trace report.
* GET /api/v1/reports/recall/{batchId}?format=json|pdf
*/
@GetMapping("/recall/{batchId}")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
public ResponseEntity<?> recallReport(
@PathVariable UUID batchId,
@RequestParam(defaultValue = "json") String format) {
UUID tenantId = TenantContext.getCurrentTenant();
RecallReport report = reportService.generateRecallReport(tenantId, batchId);
return switch (format.toLowerCase()) {
case "pdf" -> {
Club club = getClub(tenantId);
byte[] pdf = pdfGenerator.renderRecallReport(report, club);
yield ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"rueckruf-" + batchId + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
default -> ResponseEntity.ok(toRecallResponse(report));
};
}
// --- Mapping helpers ---
private Club getClub(UUID tenantId) {
return clubRepository.findByTenantId(tenantId)
.orElseThrow(() -> new IllegalStateException("Club not found for tenant " + tenantId));
}
private MonthlyReportResponse toMonthlyResponse(MonthlyReport r) {
return new MonthlyReportResponse(
r.getMonth().toString(),
r.getTotalDistributions(),
r.getTotalGrams(),
r.getUniqueMembers(),
r.getAveragePerMember(),
r.getTopStrains().stream()
.map(s -> new MonthlyReportResponse.StrainSummaryDto(
s.getName(), s.getTotalGrams(), s.getDistributionCount()))
.toList(),
r.getDailyBreakdown().stream()
.map(d -> new MonthlyReportResponse.DailyEntryDto(
d.getDate(), d.getGrams(), d.getDistributions()))
.toList()
);
}
private MemberListResponse toMemberListResponse(MemberListReport r) {
return new MemberListResponse(
r.getGeneratedAt(),
r.getMembers().stream()
.map(m -> new MemberListResponse.MemberEntryDto(
m.getId(), m.getFirstName(), m.getLastName(),
m.getMembershipNumber(),
m.getStatus() != null ? m.getStatus().name() : null,
m.getJoinDate(), m.getTotalDistributions(),
m.getLastDistributionDate()))
.toList()
);
}
private RecallReportResponse toRecallResponse(RecallReport r) {
return new RecallReportResponse(
r.getBatchId(),
r.getStrainName(),
r.getBatchNumber(),
r.getReceivedDate(),
r.getTotalGramsDistributed(),
r.getAffectedMembers().stream()
.map(am -> new RecallReportResponse.AffectedMemberDto(
am.getMemberId(), am.getFirstName(), am.getLastName(),
am.getDistributionDate(), am.getGrams()))
.toList()
);
}
}
@@ -0,0 +1,25 @@
package de.cannamanage.api.dto.report;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* JSON response DTO for the member list report.
*/
public record MemberListResponse(
Instant generatedAt,
List<MemberEntryDto> members
) {
public record MemberEntryDto(
UUID id,
String firstName,
String lastName,
String membershipNumber,
String status,
LocalDate joinDate,
int totalDistributions,
Instant lastDistributionDate
) {}
}
@@ -0,0 +1,21 @@
package de.cannamanage.api.dto.report;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* JSON response DTO for the monthly distribution report.
*/
public record MonthlyReportResponse(
String month,
int totalDistributions,
BigDecimal totalGrams,
int uniqueMembers,
BigDecimal averagePerMember,
List<StrainSummaryDto> topStrains,
List<DailyEntryDto> dailyBreakdown
) {
public record StrainSummaryDto(String name, BigDecimal totalGrams, int distributionCount) {}
public record DailyEntryDto(LocalDate date, BigDecimal grams, int distributions) {}
}
@@ -0,0 +1,27 @@
package de.cannamanage.api.dto.report;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* JSON response DTO for the recall/batch trace report.
*/
public record RecallReportResponse(
UUID batchId,
String strainName,
String batchNumber,
LocalDate receivedDate,
BigDecimal totalGramsDistributed,
List<AffectedMemberDto> affectedMembers
) {
public record AffectedMemberDto(
UUID memberId,
String firstName,
String lastName,
Instant distributionDate,
BigDecimal grams
) {}
}