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
) {}
}
+1 -1
View File
@@ -77,7 +77,7 @@
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.11.0</version>
<version>1.12.0</version>
</dependency>
</dependencies>
@@ -0,0 +1,121 @@
package de.cannamanage.service;
import de.cannamanage.service.model.report.MemberListReport;
import de.cannamanage.service.model.report.MonthlyReport;
import de.cannamanage.service.model.report.RecallReport;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
/**
* Generates CSV report exports using Apache Commons CSV.
* All output is UTF-8 with BOM prefix for Excel compatibility.
* German column headers for compliance documentation.
*/
@Component
public class CsvReportGenerator {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
.withZone(ZoneId.of("Europe/Berlin"));
private static final String UTF8_BOM = "\uFEFF";
/**
* Render monthly report as CSV with daily breakdown.
*/
public byte[] renderMonthlyReport(MonthlyReport report) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(baos, StandardCharsets.UTF_8)) {
writer.write(UTF8_BOM);
CSVPrinter csv = new CSVPrinter(writer,
CSVFormat.DEFAULT.builder()
.setHeader("Datum", "Menge (g)", "Ausgaben")
.build());
if (report.getDailyBreakdown() != null) {
for (MonthlyReport.DailyEntry entry : report.getDailyBreakdown()) {
csv.printRecord(
entry.getDate().format(DATE_FMT),
entry.getGrams().toPlainString(),
entry.getDistributions()
);
}
}
csv.flush();
return baos.toByteArray();
} catch (IOException e) {
throw new RuntimeException("CSV generation failed", e);
}
}
/**
* Render member list as CSV.
*/
public byte[] renderMemberList(MemberListReport report) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(baos, StandardCharsets.UTF_8)) {
writer.write(UTF8_BOM);
CSVPrinter csv = new CSVPrinter(writer,
CSVFormat.DEFAULT.builder()
.setHeader("Vorname", "Nachname", "Mitgliedsnr.", "Status",
"Beitritt", "Ausgaben gesamt", "Letzte Ausgabe")
.build());
for (MemberListReport.MemberEntry m : report.getMembers()) {
csv.printRecord(
m.getFirstName(),
m.getLastName(),
m.getMembershipNumber(),
m.getStatus() != null ? m.getStatus().name() : "",
m.getJoinDate() != null ? m.getJoinDate().format(DATE_FMT) : "",
m.getTotalDistributions(),
m.getLastDistributionDate() != null ? DATETIME_FMT.format(m.getLastDistributionDate()) : ""
);
}
csv.flush();
return baos.toByteArray();
} catch (IOException e) {
throw new RuntimeException("CSV generation failed", e);
}
}
/**
* Render recall report as CSV.
*/
public byte[] renderRecallReport(RecallReport report) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(baos, StandardCharsets.UTF_8)) {
writer.write(UTF8_BOM);
CSVPrinter csv = new CSVPrinter(writer,
CSVFormat.DEFAULT.builder()
.setHeader("Vorname", "Nachname", "Ausgabedatum", "Menge (g)")
.build());
for (RecallReport.AffectedMember am : report.getAffectedMembers()) {
csv.printRecord(
am.getFirstName() != null ? am.getFirstName() : "",
am.getLastName() != null ? am.getLastName() : "",
am.getDistributionDate() != null ? DATETIME_FMT.format(am.getDistributionDate()) : "",
am.getGrams().toPlainString()
);
}
csv.flush();
return baos.toByteArray();
} catch (IOException e) {
throw new RuntimeException("CSV generation failed", e);
}
}
}
@@ -0,0 +1,49 @@
package de.cannamanage.service;
import com.lowagie.text.Document;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.ColumnText;
import com.lowagie.text.pdf.PdfContentByte;
import com.lowagie.text.pdf.PdfPageEventHelper;
import com.lowagie.text.pdf.PdfWriter;
import java.awt.Color;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* PDF footer event handler.
* Adds "Erstellt am: dd.MM.yyyy HH:mm" left-aligned and "Seite N" right-aligned
* at the bottom of every page.
*/
public class PdfFooterHandler extends PdfPageEventHelper {
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.GRAY);
private static final DateTimeFormatter FOOTER_DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
private final String generatedTimestamp;
public PdfFooterHandler() {
this.generatedTimestamp = LocalDateTime.now().format(FOOTER_DATE_FORMAT);
}
@Override
public void onEndPage(PdfWriter writer, Document document) {
PdfContentByte cb = writer.getDirectContent();
Rectangle pageSize = document.getPageSize();
float bottom = document.bottomMargin() - 15;
// Left: "Erstellt am: dd.MM.yyyy HH:mm"
ColumnText.showTextAligned(cb, Element.ALIGN_LEFT,
new Phrase("Erstellt am: " + generatedTimestamp, FOOTER_FONT),
document.leftMargin(), bottom, 0);
// Right: "Seite N"
ColumnText.showTextAligned(cb, Element.ALIGN_RIGHT,
new Phrase("Seite " + writer.getPageNumber(), FOOTER_FONT),
pageSize.getWidth() - document.rightMargin(), bottom, 0);
}
}
@@ -0,0 +1,242 @@
package de.cannamanage.service;
import com.lowagie.text.*;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.service.model.report.MemberListReport;
import de.cannamanage.service.model.report.MonthlyReport;
import de.cannamanage.service.model.report.RecallReport;
import org.springframework.stereotype.Component;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
/**
* Generates PDF reports using OpenPDF (librepdf fork of iText 2.x).
* Minimal branding: club name header, report title, tables with light gray headers.
*/
@Component
public class PdfReportGenerator {
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font TITLE_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Color HEADER_BG = new Color(220, 220, 220);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
.withZone(ZoneId.of("Europe/Berlin"));
/**
* Render a monthly distribution report as PDF.
*/
public byte[] renderMonthlyReport(MonthlyReport report, Club club) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
try {
PdfWriter writer = PdfWriter.getInstance(document, baos);
writer.setPageEvent(new PdfFooterHandler());
document.open();
// Club header
Paragraph clubHeader = new Paragraph(club.getName(), HEADER_FONT);
clubHeader.setSpacingAfter(5);
document.add(clubHeader);
// Report title
Paragraph title = new Paragraph("Monatsbericht — " + report.getMonth().toString(), TITLE_FONT);
title.setSpacingAfter(15);
document.add(title);
// Summary table
PdfPTable summary = new PdfPTable(2);
summary.setWidthPercentage(60);
summary.setHorizontalAlignment(Element.ALIGN_LEFT);
summary.setSpacingAfter(15);
addSummaryRow(summary, "Ausgaben gesamt", String.valueOf(report.getTotalDistributions()));
addSummaryRow(summary, "Gesamtmenge (g)", report.getTotalGrams().toPlainString());
addSummaryRow(summary, "Eindeutige Mitglieder", String.valueOf(report.getUniqueMembers()));
addSummaryRow(summary, "Ø pro Mitglied (g)", report.getAveragePerMember().toPlainString());
document.add(summary);
// Top strains
if (report.getTopStrains() != null && !report.getTopStrains().isEmpty()) {
document.add(new Paragraph("Top Sorten", TITLE_FONT));
document.add(Chunk.NEWLINE);
PdfPTable strainTable = new PdfPTable(3);
strainTable.setWidthPercentage(80);
strainTable.setSpacingAfter(15);
addTableHeader(strainTable, "Sorte", "Menge (g)", "Ausgaben");
for (MonthlyReport.StrainSummary s : report.getTopStrains()) {
addCell(strainTable, s.getName());
addCell(strainTable, s.getTotalGrams().toPlainString());
addCell(strainTable, String.valueOf(s.getDistributionCount()));
}
document.add(strainTable);
}
// Daily breakdown
if (report.getDailyBreakdown() != null && !report.getDailyBreakdown().isEmpty()) {
document.add(new Paragraph("Tagesübersicht", TITLE_FONT));
document.add(Chunk.NEWLINE);
PdfPTable dailyTable = new PdfPTable(3);
dailyTable.setWidthPercentage(80);
dailyTable.setSpacingAfter(15);
addTableHeader(dailyTable, "Datum", "Menge (g)", "Ausgaben");
for (MonthlyReport.DailyEntry e : report.getDailyBreakdown()) {
addCell(dailyTable, e.getDate().format(DATE_FMT));
addCell(dailyTable, e.getGrams().toPlainString());
addCell(dailyTable, String.valueOf(e.getDistributions()));
}
document.add(dailyTable);
}
document.close();
} catch (DocumentException e) {
throw new RuntimeException("PDF generation failed", e);
}
return baos.toByteArray();
}
/**
* Render a member list report as PDF.
*/
public byte[] renderMemberList(MemberListReport report, Club club) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Document document = new Document(PageSize.A4.rotate(), 50, 50, 50, 50);
try {
PdfWriter writer = PdfWriter.getInstance(document, baos);
writer.setPageEvent(new PdfFooterHandler());
document.open();
Paragraph clubHeader = new Paragraph(club.getName(), HEADER_FONT);
clubHeader.setSpacingAfter(5);
document.add(clubHeader);
Paragraph title = new Paragraph("Mitgliederliste", TITLE_FONT);
title.setSpacingAfter(15);
document.add(title);
PdfPTable table = new PdfPTable(7);
table.setWidthPercentage(100);
table.setWidths(new float[]{2f, 2f, 2f, 2f, 1.5f, 1.5f, 2.5f});
addTableHeader(table, "Vorname", "Nachname", "Mitgliedsnr.", "Status",
"Beitritt", "Ausgaben", "Letzte Ausgabe");
for (MemberListReport.MemberEntry m : report.getMembers()) {
addCell(table, m.getFirstName());
addCell(table, m.getLastName());
addCell(table, m.getMembershipNumber());
addCell(table, m.getStatus() != null ? m.getStatus().name() : "");
addCell(table, m.getJoinDate() != null ? m.getJoinDate().format(DATE_FMT) : "");
addCell(table, String.valueOf(m.getTotalDistributions()));
addCell(table, m.getLastDistributionDate() != null
? DATETIME_FMT.format(m.getLastDistributionDate()) : "");
}
document.add(table);
document.close();
} catch (DocumentException e) {
throw new RuntimeException("PDF generation failed", e);
}
return baos.toByteArray();
}
/**
* Render a recall/batch trace report as PDF.
*/
public byte[] renderRecallReport(RecallReport report, Club club) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
try {
PdfWriter writer = PdfWriter.getInstance(document, baos);
writer.setPageEvent(new PdfFooterHandler());
document.open();
Paragraph clubHeader = new Paragraph(club.getName(), HEADER_FONT);
clubHeader.setSpacingAfter(5);
document.add(clubHeader);
Paragraph title = new Paragraph("Rückruf-Bericht — Charge " + report.getBatchNumber(), TITLE_FONT);
title.setSpacingAfter(15);
document.add(title);
// Batch info
PdfPTable info = new PdfPTable(2);
info.setWidthPercentage(60);
info.setHorizontalAlignment(Element.ALIGN_LEFT);
info.setSpacingAfter(15);
addSummaryRow(info, "Sorte", report.getStrainName());
addSummaryRow(info, "Chargen-Nr.", report.getBatchNumber());
addSummaryRow(info, "Erntedatum",
report.getReceivedDate() != null ? report.getReceivedDate().format(DATE_FMT) : "");
addSummaryRow(info, "Verteilte Menge (g)", report.getTotalGramsDistributed().toPlainString());
addSummaryRow(info, "Betroffene Mitglieder", String.valueOf(report.getAffectedMembers().size()));
document.add(info);
// Affected members table
document.add(new Paragraph("Betroffene Mitglieder", TITLE_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(4);
table.setWidthPercentage(100);
addTableHeader(table, "Vorname", "Nachname", "Ausgabedatum", "Menge (g)");
for (RecallReport.AffectedMember am : report.getAffectedMembers()) {
addCell(table, am.getFirstName() != null ? am.getFirstName() : "");
addCell(table, am.getLastName() != null ? am.getLastName() : "");
addCell(table, am.getDistributionDate() != null
? DATETIME_FMT.format(am.getDistributionDate()) : "");
addCell(table, am.getGrams().toPlainString());
}
document.add(table);
document.close();
} catch (DocumentException e) {
throw new RuntimeException("PDF generation failed", e);
}
return baos.toByteArray();
}
// --- Helper methods ---
private void addTableHeader(PdfPTable table, String... headers) {
for (String h : headers) {
PdfPCell cell = new PdfPCell(new Phrase(h, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
table.addCell(cell);
}
}
private void addCell(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_CELL_FONT));
cell.setPadding(4);
table.addCell(cell);
}
private void addSummaryRow(PdfPTable table, String label, String value) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, TABLE_HEADER_FONT));
labelCell.setBorder(Rectangle.NO_BORDER);
labelCell.setPadding(4);
table.addCell(labelCell);
PdfPCell valueCell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
valueCell.setBorder(Rectangle.NO_BORDER);
valueCell.setPadding(4);
table.addCell(valueCell);
}
}
@@ -0,0 +1,215 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.Strain;
import de.cannamanage.domain.enums.MemberStatus;
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.BatchRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.StrainRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
import java.util.stream.Collectors;
/**
* Generates report data for compliance and operational reporting.
* All methods are read-only transactions scoped to a single tenant.
*/
@Service
@Transactional(readOnly = true)
public class ReportService {
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
private final DistributionRepository distributionRepository;
private final MemberRepository memberRepository;
private final BatchRepository batchRepository;
private final StrainRepository strainRepository;
public ReportService(DistributionRepository distributionRepository,
MemberRepository memberRepository,
BatchRepository batchRepository,
StrainRepository strainRepository) {
this.distributionRepository = distributionRepository;
this.memberRepository = memberRepository;
this.batchRepository = batchRepository;
this.strainRepository = strainRepository;
}
/**
* Generate a monthly distribution report for the given tenant and month.
*/
public MonthlyReport generateMonthlyReport(UUID tenantId, YearMonth month) {
LocalDate startDate = month.atDay(1);
LocalDate endDate = month.atEndOfMonth().plusDays(1);
Instant start = startDate.atStartOfDay(BERLIN).toInstant();
Instant end = endDate.atStartOfDay(BERLIN).toInstant();
List<Distribution> distributions = distributionRepository.findByTenantIdAndDistributedAtBetween(tenantId, start, end);
MonthlyReport report = new MonthlyReport();
report.setMonth(month);
report.setTotalDistributions(distributions.size());
BigDecimal totalGrams = distributions.stream()
.map(Distribution::getQuantityGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
report.setTotalGrams(totalGrams);
Set<UUID> uniqueMemberIds = distributions.stream()
.map(Distribution::getMemberId)
.collect(Collectors.toSet());
report.setUniqueMembers(uniqueMemberIds.size());
if (!uniqueMemberIds.isEmpty()) {
report.setAveragePerMember(totalGrams.divide(
BigDecimal.valueOf(uniqueMemberIds.size()), 2, RoundingMode.HALF_UP));
} else {
report.setAveragePerMember(BigDecimal.ZERO);
}
// Top strains by total grams
Map<UUID, List<Distribution>> byBatch = distributions.stream()
.collect(Collectors.groupingBy(Distribution::getBatchId));
Map<UUID, BigDecimal> gramsByStrain = new HashMap<>();
Map<UUID, Integer> countByStrain = new HashMap<>();
for (Map.Entry<UUID, List<Distribution>> entry : byBatch.entrySet()) {
UUID batchId = entry.getKey();
Optional<Batch> batchOpt = batchRepository.findById(batchId);
if (batchOpt.isPresent()) {
UUID strainId = batchOpt.get().getStrainId();
BigDecimal batchGrams = entry.getValue().stream()
.map(Distribution::getQuantityGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
gramsByStrain.merge(strainId, batchGrams, BigDecimal::add);
countByStrain.merge(strainId, entry.getValue().size(), Integer::sum);
}
}
List<MonthlyReport.StrainSummary> topStrains = gramsByStrain.entrySet().stream()
.sorted(Map.Entry.<UUID, BigDecimal>comparingByValue().reversed())
.limit(10)
.map(e -> {
String strainName = strainRepository.findById(e.getKey())
.map(Strain::getName)
.orElse("Unbekannt");
return new MonthlyReport.StrainSummary(strainName, e.getValue(),
countByStrain.getOrDefault(e.getKey(), 0));
})
.toList();
report.setTopStrains(topStrains);
// Daily breakdown
Map<LocalDate, List<Distribution>> byDay = distributions.stream()
.collect(Collectors.groupingBy(d ->
d.getDistributedAt().atZone(BERLIN).toLocalDate()));
List<MonthlyReport.DailyEntry> dailyEntries = new ArrayList<>();
LocalDate current = startDate;
while (current.isBefore(endDate)) {
List<Distribution> dayDists = byDay.getOrDefault(current, List.of());
BigDecimal dayGrams = dayDists.stream()
.map(Distribution::getQuantityGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
dailyEntries.add(new MonthlyReport.DailyEntry(current, dayGrams, dayDists.size()));
current = current.plusDays(1);
}
report.setDailyBreakdown(dailyEntries);
return report;
}
/**
* Generate a member list report, optionally filtered by status.
*/
public MemberListReport generateMemberListReport(UUID tenantId, MemberStatus filterStatus) {
List<Member> members;
if (filterStatus != null) {
members = memberRepository.findByTenantIdAndStatus(tenantId, filterStatus);
} else {
members = memberRepository.findByTenantId(tenantId);
}
MemberListReport report = new MemberListReport();
report.setGeneratedAt(Instant.now());
List<MemberListReport.MemberEntry> entries = members.stream()
.map(m -> {
MemberListReport.MemberEntry entry = new MemberListReport.MemberEntry();
entry.setId(m.getId());
entry.setFirstName(m.getFirstName());
entry.setLastName(m.getLastName());
entry.setMembershipNumber(m.getMembershipNumber());
entry.setStatus(m.getStatus());
entry.setJoinDate(m.getMembershipDate());
entry.setTotalDistributions((int) distributionRepository.countByTenantIdAndMemberId(tenantId, m.getId()));
Distribution latest = distributionRepository.findLatestByTenantIdAndMemberId(tenantId, m.getId());
entry.setLastDistributionDate(latest != null ? latest.getDistributedAt() : null);
return entry;
})
.toList();
report.setMembers(entries);
return report;
}
/**
* Generate a recall report for a specific batch.
* Traces all distributions from that batch back to affected members.
*/
public RecallReport generateRecallReport(UUID tenantId, UUID batchId) {
Batch batch = batchRepository.findById(batchId)
.orElseThrow(() -> new de.cannamanage.service.exception.BatchNotFoundException(batchId));
String strainName = strainRepository.findById(batch.getStrainId())
.map(Strain::getName)
.orElse("Unbekannt");
List<Distribution> distributions = distributionRepository.findByTenantIdAndBatchId(tenantId, batchId);
BigDecimal totalDistributed = distributions.stream()
.map(Distribution::getQuantityGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Resolve member details for each distribution
List<RecallReport.AffectedMember> affectedMembers = distributions.stream()
.map(d -> {
RecallReport.AffectedMember am = new RecallReport.AffectedMember();
am.setMemberId(d.getMemberId());
am.setDistributionDate(d.getDistributedAt());
am.setGrams(d.getQuantityGrams());
memberRepository.findById(d.getMemberId()).ifPresent(member -> {
am.setFirstName(member.getFirstName());
am.setLastName(member.getLastName());
});
return am;
})
.toList();
RecallReport report = new RecallReport();
report.setBatchId(batchId);
report.setStrainName(strainName);
report.setBatchNumber(batch.getBatchCode());
report.setReceivedDate(batch.getHarvestDate());
report.setTotalGramsDistributed(totalDistributed);
report.setAffectedMembers(affectedMembers);
return report;
}
}
@@ -0,0 +1,62 @@
package de.cannamanage.service.model.report;
import de.cannamanage.domain.enums.MemberStatus;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Member list report data model.
* Lists all members of a club with distribution statistics.
*/
public class MemberListReport {
private Instant generatedAt;
private List<MemberEntry> members;
public Instant getGeneratedAt() { return generatedAt; }
public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; }
public List<MemberEntry> getMembers() { return members; }
public void setMembers(List<MemberEntry> members) { this.members = members; }
/**
* A single member entry within the report.
*/
public static class MemberEntry {
private UUID id;
private String firstName;
private String lastName;
private String membershipNumber;
private MemberStatus status;
private LocalDate joinDate;
private int totalDistributions;
private Instant lastDistributionDate;
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getMembershipNumber() { return membershipNumber; }
public void setMembershipNumber(String membershipNumber) { this.membershipNumber = membershipNumber; }
public MemberStatus getStatus() { return status; }
public void setStatus(MemberStatus status) { this.status = status; }
public LocalDate getJoinDate() { return joinDate; }
public void setJoinDate(LocalDate joinDate) { this.joinDate = joinDate; }
public int getTotalDistributions() { return totalDistributions; }
public void setTotalDistributions(int totalDistributions) { this.totalDistributions = totalDistributions; }
public Instant getLastDistributionDate() { return lastDistributionDate; }
public void setLastDistributionDate(Instant lastDistributionDate) { this.lastDistributionDate = lastDistributionDate; }
}
}
@@ -0,0 +1,94 @@
package de.cannamanage.service.model.report;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
/**
* Monthly distribution report data model.
* Aggregates all distributions for a given month per tenant.
*/
public class MonthlyReport {
private YearMonth month;
private int totalDistributions;
private BigDecimal totalGrams;
private int uniqueMembers;
private BigDecimal averagePerMember;
private List<StrainSummary> topStrains;
private List<DailyEntry> dailyBreakdown;
public YearMonth getMonth() { return month; }
public void setMonth(YearMonth month) { this.month = month; }
public int getTotalDistributions() { return totalDistributions; }
public void setTotalDistributions(int totalDistributions) { this.totalDistributions = totalDistributions; }
public BigDecimal getTotalGrams() { return totalGrams; }
public void setTotalGrams(BigDecimal totalGrams) { this.totalGrams = totalGrams; }
public int getUniqueMembers() { return uniqueMembers; }
public void setUniqueMembers(int uniqueMembers) { this.uniqueMembers = uniqueMembers; }
public BigDecimal getAveragePerMember() { return averagePerMember; }
public void setAveragePerMember(BigDecimal averagePerMember) { this.averagePerMember = averagePerMember; }
public List<StrainSummary> getTopStrains() { return topStrains; }
public void setTopStrains(List<StrainSummary> topStrains) { this.topStrains = topStrains; }
public List<DailyEntry> getDailyBreakdown() { return dailyBreakdown; }
public void setDailyBreakdown(List<DailyEntry> dailyBreakdown) { this.dailyBreakdown = dailyBreakdown; }
/**
* Summary of a single strain's distribution totals within the report period.
*/
public static class StrainSummary {
private String name;
private BigDecimal totalGrams;
private int distributionCount;
public StrainSummary() {}
public StrainSummary(String name, BigDecimal totalGrams, int distributionCount) {
this.name = name;
this.totalGrams = totalGrams;
this.distributionCount = distributionCount;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public BigDecimal getTotalGrams() { return totalGrams; }
public void setTotalGrams(BigDecimal totalGrams) { this.totalGrams = totalGrams; }
public int getDistributionCount() { return distributionCount; }
public void setDistributionCount(int distributionCount) { this.distributionCount = distributionCount; }
}
/**
* A single day's aggregate within the monthly breakdown.
*/
public static class DailyEntry {
private LocalDate date;
private BigDecimal grams;
private int distributions;
public DailyEntry() {}
public DailyEntry(LocalDate date, BigDecimal grams, int distributions) {
this.date = date;
this.grams = grams;
this.distributions = distributions;
}
public LocalDate getDate() { return date; }
public void setDate(LocalDate date) { this.date = date; }
public BigDecimal getGrams() { return grams; }
public void setGrams(BigDecimal grams) { this.grams = grams; }
public int getDistributions() { return distributions; }
public void setDistributions(int distributions) { this.distributions = distributions; }
}
}
@@ -0,0 +1,66 @@
package de.cannamanage.service.model.report;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Recall report data model.
* Traces all distributions from a specific batch back to affected members.
* Critical for CanG §26 compliance — enables rapid member notification on contamination.
*/
public class RecallReport {
private UUID batchId;
private String strainName;
private String batchNumber;
private LocalDate receivedDate;
private BigDecimal totalGramsDistributed;
private List<AffectedMember> affectedMembers;
public UUID getBatchId() { return batchId; }
public void setBatchId(UUID batchId) { this.batchId = batchId; }
public String getStrainName() { return strainName; }
public void setStrainName(String strainName) { this.strainName = strainName; }
public String getBatchNumber() { return batchNumber; }
public void setBatchNumber(String batchNumber) { this.batchNumber = batchNumber; }
public LocalDate getReceivedDate() { return receivedDate; }
public void setReceivedDate(LocalDate receivedDate) { this.receivedDate = receivedDate; }
public BigDecimal getTotalGramsDistributed() { return totalGramsDistributed; }
public void setTotalGramsDistributed(BigDecimal totalGramsDistributed) { this.totalGramsDistributed = totalGramsDistributed; }
public List<AffectedMember> getAffectedMembers() { return affectedMembers; }
public void setAffectedMembers(List<AffectedMember> affectedMembers) { this.affectedMembers = affectedMembers; }
/**
* A member who received cannabis from the recalled batch.
*/
public static class AffectedMember {
private UUID memberId;
private String firstName;
private String lastName;
private Instant distributionDate;
private BigDecimal grams;
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public Instant getDistributionDate() { return distributionDate; }
public void setDistributionDate(Instant distributionDate) { this.distributionDate = distributionDate; }
public BigDecimal getGrams() { return grams; }
public void setGrams(BigDecimal grams) { this.grams = grams; }
}
}
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Repository
@@ -29,4 +30,26 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
@Param("tenantId") UUID tenantId,
@Param("after") Instant after
);
/**
* Find all distributions for a tenant within a time range.
*/
List<Distribution> findByTenantIdAndDistributedAtBetween(UUID tenantId, Instant start, Instant end);
/**
* Find all distributions for a specific batch (for recall reports).
*/
List<Distribution> findByTenantIdAndBatchId(UUID tenantId, UUID batchId);
/**
* Count distributions for a specific member within a tenant.
*/
long countByTenantIdAndMemberId(UUID tenantId, UUID memberId);
/**
* Find the most recent distribution for a member.
*/
@Query("SELECT d FROM Distribution d WHERE d.tenantId = :tenantId AND d.memberId = :memberId " +
"ORDER BY d.distributedAt DESC LIMIT 1")
Distribution findLatestByTenantIdAndMemberId(@Param("tenantId") UUID tenantId, @Param("memberId") UUID memberId);
}
@@ -5,6 +5,7 @@ import de.cannamanage.domain.enums.MemberStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
@@ -13,4 +14,14 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
long countByTenantId(UUID tenantId);
long countByTenantIdAndStatus(UUID tenantId, MemberStatus status);
/**
* Find all members for a tenant, optionally filtered by status.
*/
List<Member> findByTenantIdAndStatus(UUID tenantId, MemberStatus status);
/**
* Find all members for a tenant (all statuses).
*/
List<Member> findByTenantId(UUID tenantId);
}
@@ -0,0 +1,118 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.service.model.report.MemberListReport;
import de.cannamanage.service.model.report.MonthlyReport;
import de.cannamanage.service.model.report.RecallReport;
import de.cannamanage.domain.enums.MemberStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class PdfReportGeneratorTest {
private PdfReportGenerator generator;
private Club club;
@BeforeEach
void setUp() {
generator = new PdfReportGenerator();
club = new Club();
club.setName("Grüne Freunde e.V.");
}
@Test
void testRenderMonthlyReport_validPdf() {
MonthlyReport report = new MonthlyReport();
report.setMonth(YearMonth.of(2026, 3));
report.setTotalDistributions(42);
report.setTotalGrams(new BigDecimal("210.50"));
report.setUniqueMembers(15);
report.setAveragePerMember(new BigDecimal("14.03"));
report.setTopStrains(List.of(
new MonthlyReport.StrainSummary("White Widow", new BigDecimal("80.00"), 18),
new MonthlyReport.StrainSummary("Amnesia Haze", new BigDecimal("60.00"), 12)
));
report.setDailyBreakdown(List.of(
new MonthlyReport.DailyEntry(LocalDate.of(2026, 3, 1), new BigDecimal("15.00"), 3),
new MonthlyReport.DailyEntry(LocalDate.of(2026, 3, 2), new BigDecimal("8.50"), 2)
));
byte[] pdf = generator.renderMonthlyReport(report, club);
assertThat(pdf).isNotEmpty();
assertThat(pdf).startsWith("%PDF".getBytes());
}
@Test
void testRenderMemberList_validPdf() {
MemberListReport report = new MemberListReport();
report.setGeneratedAt(Instant.now());
MemberListReport.MemberEntry entry = new MemberListReport.MemberEntry();
entry.setId(UUID.randomUUID());
entry.setFirstName("Max");
entry.setLastName("Mustermann");
entry.setMembershipNumber("M-001");
entry.setStatus(MemberStatus.ACTIVE);
entry.setJoinDate(LocalDate.of(2025, 6, 1));
entry.setTotalDistributions(12);
entry.setLastDistributionDate(Instant.parse("2026-03-15T10:00:00Z"));
report.setMembers(List.of(entry));
byte[] pdf = generator.renderMemberList(report, club);
assertThat(pdf).isNotEmpty();
assertThat(pdf).startsWith("%PDF".getBytes());
}
@Test
void testRenderRecallReport_validPdf() {
RecallReport report = new RecallReport();
report.setBatchId(UUID.randomUUID());
report.setStrainName("Northern Lights");
report.setBatchNumber("BATCH-2026-007");
report.setReceivedDate(LocalDate.of(2026, 2, 20));
report.setTotalGramsDistributed(new BigDecimal("45.00"));
RecallReport.AffectedMember am = new RecallReport.AffectedMember();
am.setMemberId(UUID.randomUUID());
am.setFirstName("Anna");
am.setLastName("Schmidt");
am.setDistributionDate(Instant.parse("2026-03-05T12:00:00Z"));
am.setGrams(new BigDecimal("5.00"));
report.setAffectedMembers(List.of(am));
byte[] pdf = generator.renderRecallReport(report, club);
assertThat(pdf).isNotEmpty();
assertThat(pdf).startsWith("%PDF".getBytes());
}
@Test
void testRenderMonthlyReport_emptyReport() {
MonthlyReport report = new MonthlyReport();
report.setMonth(YearMonth.of(2026, 1));
report.setTotalDistributions(0);
report.setTotalGrams(BigDecimal.ZERO);
report.setUniqueMembers(0);
report.setAveragePerMember(BigDecimal.ZERO);
report.setTopStrains(List.of());
report.setDailyBreakdown(List.of());
byte[] pdf = generator.renderMonthlyReport(report, club);
assertThat(pdf).isNotEmpty();
assertThat(pdf).startsWith("%PDF".getBytes());
}
}
@@ -0,0 +1,223 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.Strain;
import de.cannamanage.domain.enums.BatchStatus;
import de.cannamanage.domain.enums.MemberStatus;
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.BatchRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.StrainRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
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.when;
@ExtendWith(MockitoExtension.class)
class ReportServiceTest {
@Mock
private DistributionRepository distributionRepository;
@Mock
private MemberRepository memberRepository;
@Mock
private BatchRepository batchRepository;
@Mock
private StrainRepository strainRepository;
@InjectMocks
private ReportService reportService;
private static final UUID TENANT_ID = UUID.randomUUID();
private static final UUID MEMBER_1 = UUID.randomUUID();
private static final UUID MEMBER_2 = UUID.randomUUID();
private static final UUID BATCH_ID = UUID.randomUUID();
private static final UUID STRAIN_ID = UUID.randomUUID();
@Test
void testGenerateMonthlyReport_withDistributions() {
YearMonth month = YearMonth.of(2026, 3);
Distribution d1 = createDistribution(MEMBER_1, BATCH_ID, new BigDecimal("3.50"),
Instant.parse("2026-03-10T14:00:00Z"));
Distribution d2 = createDistribution(MEMBER_2, BATCH_ID, new BigDecimal("5.00"),
Instant.parse("2026-03-15T10:00:00Z"));
Distribution d3 = createDistribution(MEMBER_1, BATCH_ID, new BigDecimal("2.50"),
Instant.parse("2026-03-15T16:00:00Z"));
when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(TENANT_ID), any(), any()))
.thenReturn(List.of(d1, d2, d3));
Batch batch = createBatch(BATCH_ID, STRAIN_ID, "BATCH-001");
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batch));
Strain strain = new Strain();
strain.setId(STRAIN_ID);
strain.setName("White Widow");
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(strain));
MonthlyReport report = reportService.generateMonthlyReport(TENANT_ID, month);
assertThat(report.getMonth()).isEqualTo(month);
assertThat(report.getTotalDistributions()).isEqualTo(3);
assertThat(report.getTotalGrams()).isEqualByComparingTo("11.00");
assertThat(report.getUniqueMembers()).isEqualTo(2);
assertThat(report.getAveragePerMember()).isEqualByComparingTo("5.50");
assertThat(report.getTopStrains()).hasSize(1);
assertThat(report.getTopStrains().get(0).getName()).isEqualTo("White Widow");
assertThat(report.getDailyBreakdown()).hasSize(31); // March has 31 days
}
@Test
void testGenerateMonthlyReport_emptyMonth() {
YearMonth month = YearMonth.of(2026, 1);
when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(TENANT_ID), any(), any()))
.thenReturn(List.of());
MonthlyReport report = reportService.generateMonthlyReport(TENANT_ID, month);
assertThat(report.getTotalDistributions()).isZero();
assertThat(report.getTotalGrams()).isEqualByComparingTo("0");
assertThat(report.getUniqueMembers()).isZero();
assertThat(report.getAveragePerMember()).isEqualByComparingTo("0");
assertThat(report.getTopStrains()).isEmpty();
assertThat(report.getDailyBreakdown()).hasSize(31);
}
@Test
void testGenerateMemberListReport_allMembers() {
Member m1 = createMember(MEMBER_1, "Max", "Mustermann", "M-001", MemberStatus.ACTIVE);
Member m2 = createMember(MEMBER_2, "Anna", "Muster", "M-002", MemberStatus.SUSPENDED);
when(memberRepository.findByTenantId(TENANT_ID)).thenReturn(List.of(m1, m2));
when(distributionRepository.countByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(5L);
when(distributionRepository.countByTenantIdAndMemberId(TENANT_ID, MEMBER_2)).thenReturn(0L);
when(distributionRepository.findLatestByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(null);
when(distributionRepository.findLatestByTenantIdAndMemberId(TENANT_ID, MEMBER_2)).thenReturn(null);
MemberListReport report = reportService.generateMemberListReport(TENANT_ID, null);
assertThat(report.getGeneratedAt()).isNotNull();
assertThat(report.getMembers()).hasSize(2);
assertThat(report.getMembers().get(0).getFirstName()).isEqualTo("Max");
assertThat(report.getMembers().get(0).getTotalDistributions()).isEqualTo(5);
assertThat(report.getMembers().get(1).getStatus()).isEqualTo(MemberStatus.SUSPENDED);
}
@Test
void testGenerateMemberListReport_filteredByStatus() {
Member m1 = createMember(MEMBER_1, "Max", "Mustermann", "M-001", MemberStatus.ACTIVE);
when(memberRepository.findByTenantIdAndStatus(TENANT_ID, MemberStatus.ACTIVE))
.thenReturn(List.of(m1));
when(distributionRepository.countByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(3L);
when(distributionRepository.findLatestByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(null);
MemberListReport report = reportService.generateMemberListReport(TENANT_ID, MemberStatus.ACTIVE);
assertThat(report.getMembers()).hasSize(1);
assertThat(report.getMembers().get(0).getStatus()).isEqualTo(MemberStatus.ACTIVE);
}
@Test
void testGenerateRecallReport_success() {
Batch batch = createBatch(BATCH_ID, STRAIN_ID, "BATCH-RECALL-01");
batch.setHarvestDate(LocalDate.of(2026, 2, 15));
Strain strain = new Strain();
strain.setId(STRAIN_ID);
strain.setName("Amnesia Haze");
Distribution d1 = createDistribution(MEMBER_1, BATCH_ID, new BigDecimal("5.00"),
Instant.parse("2026-03-01T10:00:00Z"));
Distribution d2 = createDistribution(MEMBER_2, BATCH_ID, new BigDecimal("3.00"),
Instant.parse("2026-03-02T14:00:00Z"));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(strain));
when(distributionRepository.findByTenantIdAndBatchId(TENANT_ID, BATCH_ID))
.thenReturn(List.of(d1, d2));
Member member1 = createMember(MEMBER_1, "Max", "Mustermann", "M-001", MemberStatus.ACTIVE);
Member member2 = createMember(MEMBER_2, "Anna", "Muster", "M-002", MemberStatus.ACTIVE);
when(memberRepository.findById(MEMBER_1)).thenReturn(Optional.of(member1));
when(memberRepository.findById(MEMBER_2)).thenReturn(Optional.of(member2));
RecallReport report = reportService.generateRecallReport(TENANT_ID, BATCH_ID);
assertThat(report.getBatchId()).isEqualTo(BATCH_ID);
assertThat(report.getStrainName()).isEqualTo("Amnesia Haze");
assertThat(report.getBatchNumber()).isEqualTo("BATCH-RECALL-01");
assertThat(report.getReceivedDate()).isEqualTo(LocalDate.of(2026, 2, 15));
assertThat(report.getTotalGramsDistributed()).isEqualByComparingTo("8.00");
assertThat(report.getAffectedMembers()).hasSize(2);
assertThat(report.getAffectedMembers().get(0).getFirstName()).isEqualTo("Max");
assertThat(report.getAffectedMembers().get(1).getGrams()).isEqualByComparingTo("3.00");
}
@Test
void testGenerateRecallReport_batchNotFound() {
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> reportService.generateRecallReport(TENANT_ID, BATCH_ID))
.isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
}
// --- Helper methods ---
private Distribution createDistribution(UUID memberId, UUID batchId, BigDecimal grams, Instant at) {
Distribution d = new Distribution();
d.setId(UUID.randomUUID());
d.setTenantId(TENANT_ID);
d.setMemberId(memberId);
d.setBatchId(batchId);
d.setQuantityGrams(grams);
d.setDistributedAt(at);
return d;
}
private Member createMember(UUID id, String first, String last, String number, MemberStatus status) {
Member m = new Member();
m.setId(id);
m.setTenantId(TENANT_ID);
m.setFirstName(first);
m.setLastName(last);
m.setMembershipNumber(number);
m.setStatus(status);
m.setMembershipDate(LocalDate.of(2025, 6, 1));
return m;
}
private Batch createBatch(UUID id, UUID strainId, String code) {
Batch b = new Batch();
b.setId(id);
b.setTenantId(TENANT_ID);
b.setStrainId(strainId);
b.setBatchCode(code);
b.setQuantityGrams(new BigDecimal("50.00"));
b.setStatus(BatchStatus.AVAILABLE);
return b;
}
}