From 64927a324455e8a882ea9fca006be77fe0fadaa5 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Fri, 12 Jun 2026 09:38:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(sprint-3):=20Phase=204=20=E2=80=94=20repor?= =?UTF-8?q?t=20controller=20+=20PDF/CSV=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/controller/ReportController.java | 199 ++++++++++++++ .../api/dto/report/MemberListResponse.java | 25 ++ .../api/dto/report/MonthlyReportResponse.java | 21 ++ .../api/dto/report/RecallReportResponse.java | 27 ++ cannamanage-service/pom.xml | 2 +- .../service/CsvReportGenerator.java | 121 +++++++++ .../cannamanage/service/PdfFooterHandler.java | 49 ++++ .../service/PdfReportGenerator.java | 242 ++++++++++++++++++ .../de/cannamanage/service/ReportService.java | 215 ++++++++++++++++ .../model/report/MemberListReport.java | 62 +++++ .../service/model/report/MonthlyReport.java | 94 +++++++ .../service/model/report/RecallReport.java | 66 +++++ .../repository/DistributionRepository.java | 23 ++ .../service/repository/MemberRepository.java | 11 + .../service/PdfReportGeneratorTest.java | 118 +++++++++ .../service/ReportServiceTest.java | 223 ++++++++++++++++ 16 files changed, 1497 insertions(+), 1 deletion(-) create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/dto/report/MemberListResponse.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/dto/report/MonthlyReportResponse.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/dto/report/RecallReportResponse.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/CsvReportGenerator.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/PdfFooterHandler.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/PdfReportGenerator.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/ReportService.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/model/report/MemberListReport.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/model/report/MonthlyReport.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/model/report/RecallReport.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/PdfReportGeneratorTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/ReportServiceTest.java diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java new file mode 100644 index 0000000..cc2ea26 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java @@ -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() + ); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/MemberListResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/MemberListResponse.java new file mode 100644 index 0000000..1abcfde --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/MemberListResponse.java @@ -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 members +) { + public record MemberEntryDto( + UUID id, + String firstName, + String lastName, + String membershipNumber, + String status, + LocalDate joinDate, + int totalDistributions, + Instant lastDistributionDate + ) {} +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/MonthlyReportResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/MonthlyReportResponse.java new file mode 100644 index 0000000..5061b0b --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/MonthlyReportResponse.java @@ -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 topStrains, + List dailyBreakdown +) { + public record StrainSummaryDto(String name, BigDecimal totalGrams, int distributionCount) {} + public record DailyEntryDto(LocalDate date, BigDecimal grams, int distributions) {} +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/RecallReportResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/RecallReportResponse.java new file mode 100644 index 0000000..bf715d8 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/RecallReportResponse.java @@ -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 affectedMembers +) { + public record AffectedMemberDto( + UUID memberId, + String firstName, + String lastName, + Instant distributionDate, + BigDecimal grams + ) {} +} diff --git a/cannamanage-service/pom.xml b/cannamanage-service/pom.xml index e5f47d6..231740f 100644 --- a/cannamanage-service/pom.xml +++ b/cannamanage-service/pom.xml @@ -77,7 +77,7 @@ org.apache.commons commons-csv - 1.11.0 + 1.12.0 diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/CsvReportGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/CsvReportGenerator.java new file mode 100644 index 0000000..6b099e8 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/CsvReportGenerator.java @@ -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); + } + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/PdfFooterHandler.java b/cannamanage-service/src/main/java/de/cannamanage/service/PdfFooterHandler.java new file mode 100644 index 0000000..be96394 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/PdfFooterHandler.java @@ -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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/PdfReportGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/PdfReportGenerator.java new file mode 100644 index 0000000..8c6086d --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/PdfReportGenerator.java @@ -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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/ReportService.java b/cannamanage-service/src/main/java/de/cannamanage/service/ReportService.java new file mode 100644 index 0000000..be5cfc0 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/ReportService.java @@ -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 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 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> byBatch = distributions.stream() + .collect(Collectors.groupingBy(Distribution::getBatchId)); + + Map gramsByStrain = new HashMap<>(); + Map countByStrain = new HashMap<>(); + + for (Map.Entry> entry : byBatch.entrySet()) { + UUID batchId = entry.getKey(); + Optional 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 topStrains = gramsByStrain.entrySet().stream() + .sorted(Map.Entry.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> byDay = distributions.stream() + .collect(Collectors.groupingBy(d -> + d.getDistributedAt().atZone(BERLIN).toLocalDate())); + + List dailyEntries = new ArrayList<>(); + LocalDate current = startDate; + while (current.isBefore(endDate)) { + List 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 members; + if (filterStatus != null) { + members = memberRepository.findByTenantIdAndStatus(tenantId, filterStatus); + } else { + members = memberRepository.findByTenantId(tenantId); + } + + MemberListReport report = new MemberListReport(); + report.setGeneratedAt(Instant.now()); + + List 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 distributions = distributionRepository.findByTenantIdAndBatchId(tenantId, batchId); + + BigDecimal totalDistributed = distributions.stream() + .map(Distribution::getQuantityGrams) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // Resolve member details for each distribution + List 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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/model/report/MemberListReport.java b/cannamanage-service/src/main/java/de/cannamanage/service/model/report/MemberListReport.java new file mode 100644 index 0000000..c8aed70 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/model/report/MemberListReport.java @@ -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 members; + + public Instant getGeneratedAt() { return generatedAt; } + public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; } + + public List getMembers() { return members; } + public void setMembers(List 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; } + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/model/report/MonthlyReport.java b/cannamanage-service/src/main/java/de/cannamanage/service/model/report/MonthlyReport.java new file mode 100644 index 0000000..e07920f --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/model/report/MonthlyReport.java @@ -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 topStrains; + private List 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 getTopStrains() { return topStrains; } + public void setTopStrains(List topStrains) { this.topStrains = topStrains; } + + public List getDailyBreakdown() { return dailyBreakdown; } + public void setDailyBreakdown(List 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; } + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/model/report/RecallReport.java b/cannamanage-service/src/main/java/de/cannamanage/service/model/report/RecallReport.java new file mode 100644 index 0000000..bbcc1c7 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/model/report/RecallReport.java @@ -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 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 getAffectedMembers() { return affectedMembers; } + public void setAffectedMembers(List 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; } + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java index e393538..f16391a 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java @@ -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 findByTenantIdAndDistributedAtBetween(UUID tenantId, Instant start, Instant end); + + /** + * Find all distributions for a specific batch (for recall reports). + */ + List 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); } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java index 2d42fd5..adc98cb 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java @@ -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 { long countByTenantId(UUID tenantId); long countByTenantIdAndStatus(UUID tenantId, MemberStatus status); + + /** + * Find all members for a tenant, optionally filtered by status. + */ + List findByTenantIdAndStatus(UUID tenantId, MemberStatus status); + + /** + * Find all members for a tenant (all statuses). + */ + List findByTenantId(UUID tenantId); } diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/PdfReportGeneratorTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/PdfReportGeneratorTest.java new file mode 100644 index 0000000..3c6076a --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/PdfReportGeneratorTest.java @@ -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()); + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/ReportServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/ReportServiceTest.java new file mode 100644 index 0000000..3c5600e --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/ReportServiceTest.java @@ -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; + } +}