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