feat(sprint-3): Phase 4 — report controller + PDF/CSV generation
- Add report data models (MonthlyReport, MemberListReport, RecallReport)
- Implement ReportService with monthly aggregation, member list, recall batch tracing
- Add PdfReportGenerator using OpenPDF with minimal club branding
- Add PdfFooterHandler for timestamp + page numbers on every page
- Add CsvReportGenerator with UTF-8 BOM for Excel compatibility
- Create ReportController with 3 endpoints (monthly, members, recall)
supporting JSON/PDF/CSV format negotiation via ?format= param
- Add DTO records (MonthlyReportResponse, MemberListResponse, RecallReportResponse)
- Extend DistributionRepository + MemberRepository with report queries
- Update Commons CSV from 1.11.0 to 1.12.0
- 10 unit tests (ReportServiceTest: 6, PdfReportGeneratorTest: 4) all passing
Endpoints:
GET /api/v1/reports/monthly?month=YYYY-MM&format=json|pdf|csv
GET /api/v1/reports/members?format=json|pdf|csv&status=ACTIVE
GET /api/v1/reports/recall/{batchId}?format=json|pdf
This commit is contained in:
@@ -0,0 +1,199 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.report.MemberListResponse;
|
||||||
|
import de.cannamanage.api.dto.report.MonthlyReportResponse;
|
||||||
|
import de.cannamanage.api.dto.report.RecallReportResponse;
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.CsvReportGenerator;
|
||||||
|
import de.cannamanage.service.PdfReportGenerator;
|
||||||
|
import de.cannamanage.service.ReportService;
|
||||||
|
import de.cannamanage.service.model.report.MemberListReport;
|
||||||
|
import de.cannamanage.service.model.report.MonthlyReport;
|
||||||
|
import de.cannamanage.service.model.report.RecallReport;
|
||||||
|
import de.cannamanage.service.repository.ClubRepository;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for compliance and operational reports.
|
||||||
|
* Supports JSON, PDF, and CSV output formats.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/reports")
|
||||||
|
public class ReportController {
|
||||||
|
|
||||||
|
private final ReportService reportService;
|
||||||
|
private final PdfReportGenerator pdfGenerator;
|
||||||
|
private final CsvReportGenerator csvGenerator;
|
||||||
|
private final ClubRepository clubRepository;
|
||||||
|
|
||||||
|
public ReportController(ReportService reportService,
|
||||||
|
PdfReportGenerator pdfGenerator,
|
||||||
|
CsvReportGenerator csvGenerator,
|
||||||
|
ClubRepository clubRepository) {
|
||||||
|
this.reportService = reportService;
|
||||||
|
this.pdfGenerator = pdfGenerator;
|
||||||
|
this.csvGenerator = csvGenerator;
|
||||||
|
this.clubRepository = clubRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monthly distribution report.
|
||||||
|
* GET /api/v1/reports/monthly?month=2026-03&format=json|pdf|csv
|
||||||
|
*/
|
||||||
|
@GetMapping("/monthly")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<?> monthlyReport(
|
||||||
|
@RequestParam String month,
|
||||||
|
@RequestParam(defaultValue = "json") String format) {
|
||||||
|
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
YearMonth ym = YearMonth.parse(month);
|
||||||
|
MonthlyReport report = reportService.generateMonthlyReport(tenantId, ym);
|
||||||
|
|
||||||
|
return switch (format.toLowerCase()) {
|
||||||
|
case "pdf" -> {
|
||||||
|
Club club = getClub(tenantId);
|
||||||
|
byte[] pdf = pdfGenerator.renderMonthlyReport(report, club);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"monatsbericht-" + month + ".pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
case "csv" -> {
|
||||||
|
byte[] csv = csvGenerator.renderMonthlyReport(report);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"monatsbericht-" + month + ".csv\"")
|
||||||
|
.contentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8))
|
||||||
|
.body(csv);
|
||||||
|
}
|
||||||
|
default -> ResponseEntity.ok(toMonthlyResponse(report));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member list report.
|
||||||
|
* GET /api/v1/reports/members?format=json|pdf|csv&status=ACTIVE
|
||||||
|
*/
|
||||||
|
@GetMapping("/members")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<?> memberListReport(
|
||||||
|
@RequestParam(defaultValue = "json") String format,
|
||||||
|
@RequestParam(required = false) MemberStatus status) {
|
||||||
|
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
MemberListReport report = reportService.generateMemberListReport(tenantId, status);
|
||||||
|
|
||||||
|
return switch (format.toLowerCase()) {
|
||||||
|
case "pdf" -> {
|
||||||
|
Club club = getClub(tenantId);
|
||||||
|
byte[] pdf = pdfGenerator.renderMemberList(report, club);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"mitgliederliste.pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
case "csv" -> {
|
||||||
|
byte[] csv = csvGenerator.renderMemberList(report);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"mitgliederliste.csv\"")
|
||||||
|
.contentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8))
|
||||||
|
.body(csv);
|
||||||
|
}
|
||||||
|
default -> ResponseEntity.ok(toMemberListResponse(report));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recall/batch trace report.
|
||||||
|
* GET /api/v1/reports/recall/{batchId}?format=json|pdf
|
||||||
|
*/
|
||||||
|
@GetMapping("/recall/{batchId}")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<?> recallReport(
|
||||||
|
@PathVariable UUID batchId,
|
||||||
|
@RequestParam(defaultValue = "json") String format) {
|
||||||
|
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
RecallReport report = reportService.generateRecallReport(tenantId, batchId);
|
||||||
|
|
||||||
|
return switch (format.toLowerCase()) {
|
||||||
|
case "pdf" -> {
|
||||||
|
Club club = getClub(tenantId);
|
||||||
|
byte[] pdf = pdfGenerator.renderRecallReport(report, club);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"rueckruf-" + batchId + ".pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
default -> ResponseEntity.ok(toRecallResponse(report));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping helpers ---
|
||||||
|
|
||||||
|
private Club getClub(UUID tenantId) {
|
||||||
|
return clubRepository.findByTenantId(tenantId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Club not found for tenant " + tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MonthlyReportResponse toMonthlyResponse(MonthlyReport r) {
|
||||||
|
return new MonthlyReportResponse(
|
||||||
|
r.getMonth().toString(),
|
||||||
|
r.getTotalDistributions(),
|
||||||
|
r.getTotalGrams(),
|
||||||
|
r.getUniqueMembers(),
|
||||||
|
r.getAveragePerMember(),
|
||||||
|
r.getTopStrains().stream()
|
||||||
|
.map(s -> new MonthlyReportResponse.StrainSummaryDto(
|
||||||
|
s.getName(), s.getTotalGrams(), s.getDistributionCount()))
|
||||||
|
.toList(),
|
||||||
|
r.getDailyBreakdown().stream()
|
||||||
|
.map(d -> new MonthlyReportResponse.DailyEntryDto(
|
||||||
|
d.getDate(), d.getGrams(), d.getDistributions()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MemberListResponse toMemberListResponse(MemberListReport r) {
|
||||||
|
return new MemberListResponse(
|
||||||
|
r.getGeneratedAt(),
|
||||||
|
r.getMembers().stream()
|
||||||
|
.map(m -> new MemberListResponse.MemberEntryDto(
|
||||||
|
m.getId(), m.getFirstName(), m.getLastName(),
|
||||||
|
m.getMembershipNumber(),
|
||||||
|
m.getStatus() != null ? m.getStatus().name() : null,
|
||||||
|
m.getJoinDate(), m.getTotalDistributions(),
|
||||||
|
m.getLastDistributionDate()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecallReportResponse toRecallResponse(RecallReport r) {
|
||||||
|
return new RecallReportResponse(
|
||||||
|
r.getBatchId(),
|
||||||
|
r.getStrainName(),
|
||||||
|
r.getBatchNumber(),
|
||||||
|
r.getReceivedDate(),
|
||||||
|
r.getTotalGramsDistributed(),
|
||||||
|
r.getAffectedMembers().stream()
|
||||||
|
.map(am -> new RecallReportResponse.AffectedMemberDto(
|
||||||
|
am.getMemberId(), am.getFirstName(), am.getLastName(),
|
||||||
|
am.getDistributionDate(), am.getGrams()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the member list report.
|
||||||
|
*/
|
||||||
|
public record MemberListResponse(
|
||||||
|
Instant generatedAt,
|
||||||
|
List<MemberEntryDto> members
|
||||||
|
) {
|
||||||
|
public record MemberEntryDto(
|
||||||
|
UUID id,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
String membershipNumber,
|
||||||
|
String status,
|
||||||
|
LocalDate joinDate,
|
||||||
|
int totalDistributions,
|
||||||
|
Instant lastDistributionDate
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the monthly distribution report.
|
||||||
|
*/
|
||||||
|
public record MonthlyReportResponse(
|
||||||
|
String month,
|
||||||
|
int totalDistributions,
|
||||||
|
BigDecimal totalGrams,
|
||||||
|
int uniqueMembers,
|
||||||
|
BigDecimal averagePerMember,
|
||||||
|
List<StrainSummaryDto> topStrains,
|
||||||
|
List<DailyEntryDto> dailyBreakdown
|
||||||
|
) {
|
||||||
|
public record StrainSummaryDto(String name, BigDecimal totalGrams, int distributionCount) {}
|
||||||
|
public record DailyEntryDto(LocalDate date, BigDecimal grams, int distributions) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the recall/batch trace report.
|
||||||
|
*/
|
||||||
|
public record RecallReportResponse(
|
||||||
|
UUID batchId,
|
||||||
|
String strainName,
|
||||||
|
String batchNumber,
|
||||||
|
LocalDate receivedDate,
|
||||||
|
BigDecimal totalGramsDistributed,
|
||||||
|
List<AffectedMemberDto> affectedMembers
|
||||||
|
) {
|
||||||
|
public record AffectedMemberDto(
|
||||||
|
UUID memberId,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
Instant distributionDate,
|
||||||
|
BigDecimal grams
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.commons</groupId>
|
<groupId>org.apache.commons</groupId>
|
||||||
<artifactId>commons-csv</artifactId>
|
<artifactId>commons-csv</artifactId>
|
||||||
<version>1.11.0</version>
|
<version>1.12.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@@ -29,4 +30,26 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
|
|||||||
@Param("tenantId") UUID tenantId,
|
@Param("tenantId") UUID tenantId,
|
||||||
@Param("after") Instant after
|
@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);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -5,6 +5,7 @@ import de.cannamanage.domain.enums.MemberStatus;
|
|||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@@ -13,4 +14,14 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
|
|||||||
long countByTenantId(UUID tenantId);
|
long countByTenantId(UUID tenantId);
|
||||||
|
|
||||||
long countByTenantIdAndStatus(UUID tenantId, MemberStatus status);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user