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:
@@ -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
|
||||
) {}
|
||||
}
|
||||
+21
@@ -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
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user