feat(sprint9): Phase 3 — KCanG compliance reports + Behörden-Export

Implemented 6 KCanG compliance report generators and the hero
Behörden-Export feature:

- AnnualAuthorityReportGenerator: Multi-section §22 KCanG annual report
  (9 sections: Vereinsdaten, Mitgliederstatistik, Anbauübersicht,
  Weitergabe-Statistik, Bestandsführung, Vernichtung, Transport,
  Prävention, Jugendschutz)
- DistributionLogGenerator: §19(4) distribution log (PDF+CSV,
  anonymized member data per DSGVO)
- DestructionProtocolGenerator: §22 destruction protocol with
  signature lines and sequential numbering
- TransportCertificateGenerator: §22(4) transport documentation
- BestandsfuehrungGenerator: Stock flow report (PDF+CSV) with
  per-batch breakdown
- PreventionActivityReportGenerator: §23 prevention activities

Authority Export (Behörden-Export) — THE HERO FEATURE:
- AuthorityExportService: Streaming ZIP generation via ZipOutputStream
- Re-authentication required (password re-entry + BCrypt verification)
- Mandatory reason field stored in audit trail
- Rate limited: max 1 export per hour per tenant
- ZIP contains all compliance PDFs + anonymized member JSON + manifest
- Memory-efficient: PDFs generated and streamed sequentially

Endpoint: POST /api/v1/reports/authority-export
Request: { year, password, reason }
Response: StreamingResponseBody (application/zip)

Also enhanced repositories:
- DestructionRecordRepository: date-range queries + sum aggregation
- TransportRecordRepository: date-range queries
This commit is contained in:
Patrick Plate
2026-06-15 12:53:12 +02:00
parent a29c38756c
commit 3ca231dc9c
12 changed files with 2422 additions and 1 deletions
@@ -0,0 +1,666 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
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.domain.entity.DestructionRecord;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.domain.entity.GrowEntry;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.PreventionActivity;
import de.cannamanage.domain.entity.TransportRecord;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.DestructionRecordRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.GrowEntryRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.PreventionActivityRepository;
import de.cannamanage.service.repository.TransportRecordRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Generates the Annual Authority Report per §22 KCanG.
* THE most important compliance report — due January 31 each year.
* Multi-section PDF covering all aspects of the Anbauvereinigung's operations.
*/
@Service
public class AnnualAuthorityReportGenerator implements ReportGenerator<YearReportParameters> {
private static final Logger log = LoggerFactory.getLogger(AnnualAuthorityReportGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 18, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 14, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 11, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font SMALL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.ITALIC, Color.GRAY);
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final Color SECTION_BG = new Color(34, 87, 58);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final Locale GERMAN = Locale.GERMANY;
private final ClubRepository clubRepository;
private final MemberRepository memberRepository;
private final GrowEntryRepository growEntryRepository;
private final DistributionRepository distributionRepository;
private final DestructionRecordRepository destructionRecordRepository;
private final TransportRecordRepository transportRecordRepository;
private final BatchRepository batchRepository;
private final PreventionActivityRepository preventionActivityRepository;
public AnnualAuthorityReportGenerator(ClubRepository clubRepository,
MemberRepository memberRepository,
GrowEntryRepository growEntryRepository,
DistributionRepository distributionRepository,
DestructionRecordRepository destructionRecordRepository,
TransportRecordRepository transportRecordRepository,
BatchRepository batchRepository,
PreventionActivityRepository preventionActivityRepository) {
this.clubRepository = clubRepository;
this.memberRepository = memberRepository;
this.growEntryRepository = growEntryRepository;
this.distributionRepository = distributionRepository;
this.destructionRecordRepository = destructionRecordRepository;
this.transportRecordRepository = transportRecordRepository;
this.batchRepository = batchRepository;
this.preventionActivityRepository = preventionActivityRepository;
}
@Override
public ReportType getType() {
return ReportType.ANNUAL_AUTHORITY;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(YearReportParameters params, UUID clubId) {
int year = params.year();
Instant yearStart = LocalDate.of(year, 1, 1).atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
Instant yearEnd = LocalDate.of(year, 12, 31).atTime(23, 59, 59).atZone(ZoneId.of("Europe/Berlin")).toInstant();
LocalDate yearStartDate = LocalDate.of(year, 1, 1);
LocalDate yearEndDate = LocalDate.of(year, 12, 31);
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
List<Member> allMembers = memberRepository.findByTenantId(clubId);
List<Distribution> distributions = distributionRepository.findByTenantIdAndDistributedAtBetween(clubId, yearStart, yearEnd);
List<DestructionRecord> destructions = destructionRecordRepository.findByClubIdAndDestroyedAtBetweenOrderByDestroyedAtAsc(clubId, yearStart, yearEnd);
List<TransportRecord> transports = transportRecordRepository.findByClubIdAndTransportDateBetweenOrderByTransportDateAsc(clubId, yearStartDate, yearEndDate);
List<GrowEntry> growEntries = growEntryRepository.findAllByOrderByStartedAtDesc();
List<PreventionActivity> preventionActivities = preventionActivityRepository.findByClubIdAndActivityDateBetween(clubId, yearStartDate, yearEndDate);
// Filter grow entries to this year's harvests
List<GrowEntry> yearHarvests = growEntries.stream()
.filter(g -> g.getActualHarvestAt() != null)
.filter(g -> {
LocalDate harvestDate = g.getActualHarvestAt().atZone(ZoneId.of("Europe/Berlin")).toLocalDate();
return harvestDate.getYear() == year;
})
.collect(Collectors.toList());
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
// === Title Page ===
addTitlePage(document, club, year);
document.newPage();
// === Section 1: Vereinsdaten ===
addSection1ClubInfo(document, club, allMembers);
// === Section 2: Mitgliederstatistik ===
addSection2MemberStats(document, allMembers, year);
// === Section 3: Anbauübersicht (§22 KCanG) ===
addSection3GrowOverview(document, yearHarvests);
// === Section 4: Weitergabe-Statistik (§19 KCanG) ===
addSection4DistributionStats(document, distributions, allMembers);
// === Section 5: Bestandsführung ===
addSection5StockManagement(document, yearHarvests, distributions, destructions);
// === Section 6: Vernichtungsprotokolle ===
addSection6Destructions(document, destructions);
// === Section 7: Transportdokumentationen ===
addSection7Transports(document, transports);
// === Section 8: Präventionsmaßnahmen (§23 KCanG) ===
addSection8Prevention(document, preventionActivities, allMembers);
// === Section 9: Jugendschutzmaßnahmen (§20 KCanG) ===
addSection9YouthProtection(document, distributions, allMembers);
// === Footer on every page ===
document.add(Chunk.NEWLINE);
addFooter(document, club, year);
document.close();
log.info("Generated Annual Authority Report for club {} year {}", clubId, year);
return baos.toByteArray();
} catch (Exception e) {
log.error("Failed to generate Annual Authority Report for club {} year {}", clubId, year, e);
throw new RuntimeException("PDF generation failed", e);
}
}
private void addTitlePage(Document document, Club club, int year) throws DocumentException {
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
Paragraph title = new Paragraph("JAHRESBERICHT", HEADER_FONT);
title.setAlignment(Element.ALIGN_CENTER);
document.add(title);
Paragraph subtitle = new Paragraph("gemäß §22 KCanG", SECTION_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
Paragraph period = new Paragraph("Berichtszeitraum: 01.01." + year + " 31.12." + year, SUBSECTION_FONT);
period.setAlignment(Element.ALIGN_CENTER);
document.add(period);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
Paragraph clubInfo = new Paragraph();
clubInfo.setAlignment(Element.ALIGN_CENTER);
clubInfo.add(new Chunk(club.getName(), SECTION_FONT));
clubInfo.add(Chunk.NEWLINE);
if (club.getAddressStreet() != null) {
clubInfo.add(new Chunk(club.getAddressStreet(), NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
}
if (club.getAddressPostalCode() != null || club.getAddressCity() != null) {
String addr = (club.getAddressPostalCode() != null ? club.getAddressPostalCode() + " " : "")
+ (club.getAddressCity() != null ? club.getAddressCity() : "");
clubInfo.add(new Chunk(addr, NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
}
clubInfo.add(Chunk.NEWLINE);
clubInfo.add(new Chunk("Erlaubnisnummer: " + club.getLicenseNumber(), NORMAL_FONT));
if (club.getRegistrationNumber() != null) {
clubInfo.add(Chunk.NEWLINE);
clubInfo.add(new Chunk("Vereinsregisternummer: " + club.getRegistrationNumber(), NORMAL_FONT));
}
document.add(clubInfo);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
Paragraph generated = new Paragraph("Erstellt am: " + LocalDate.now().format(DATE_FMT), SMALL_FONT);
generated.setAlignment(Element.ALIGN_CENTER);
document.add(generated);
}
private void addSection1ClubInfo(Document document, Club club, List<Member> members) throws DocumentException {
addSectionHeader(document, "1. Vereinsdaten");
PdfPTable table = new PdfPTable(2);
table.setWidthPercentage(80);
table.setWidths(new float[]{1.5f, 3f});
addInfoRow(table, "Name", club.getName());
addInfoRow(table, "Erlaubnisnummer", club.getLicenseNumber());
addInfoRow(table, "Vereinsregisternummer", club.getRegistrationNumber() != null ? club.getRegistrationNumber() : "");
addInfoRow(table, "Anschrift", formatAddress(club));
addInfoRow(table, "Kontakt-E-Mail", club.getContactEmail() != null ? club.getContactEmail() : "");
addInfoRow(table, "Kontakt-Telefon", club.getContactPhone() != null ? club.getContactPhone() : "");
long activeMembers = members.stream().filter(m -> m.getStatus() == MemberStatus.ACTIVE).count();
addInfoRow(table, "Aktive Mitglieder", String.valueOf(activeMembers));
addInfoRow(table, "Max. Mitglieder", String.valueOf(club.getMaxMembers()));
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection2MemberStats(Document document, List<Member> members, int year) throws DocumentException {
addSectionHeader(document, "2. Mitgliederstatistik");
long total = members.size();
long active = members.stream().filter(m -> m.getStatus() == MemberStatus.ACTIVE).count();
long newMembers = members.stream()
.filter(m -> m.getMembershipDate().getYear() == year)
.count();
long under21 = members.stream().filter(Member::isUnder21).count();
// Age distribution
Map<String, Long> ageGroups = new LinkedHashMap<>();
ageGroups.put("1821", members.stream().filter(m -> getAge(m, year) >= 18 && getAge(m, year) <= 21).count());
ageGroups.put("2230", members.stream().filter(m -> getAge(m, year) >= 22 && getAge(m, year) <= 30).count());
ageGroups.put("3140", members.stream().filter(m -> getAge(m, year) >= 31 && getAge(m, year) <= 40).count());
ageGroups.put("4150", members.stream().filter(m -> getAge(m, year) >= 41 && getAge(m, year) <= 50).count());
ageGroups.put("5165", members.stream().filter(m -> getAge(m, year) >= 51 && getAge(m, year) <= 65).count());
ageGroups.put("65+", members.stream().filter(m -> getAge(m, year) > 65).count());
PdfPTable statsTable = new PdfPTable(2);
statsTable.setWidthPercentage(60);
statsTable.setWidths(new float[]{2f, 1f});
addInfoRow(statsTable, "Gesamtmitglieder", String.valueOf(total));
addInfoRow(statsTable, "Davon aktiv", String.valueOf(active));
addInfoRow(statsTable, "Neueintritte im Berichtsjahr", String.valueOf(newMembers));
addInfoRow(statsTable, "Unter 21 Jahre (U21)", String.valueOf(under21));
document.add(statsTable);
document.add(Chunk.NEWLINE);
// Age distribution table
document.add(new Paragraph("Altersverteilung:", SUBSECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable ageTable = new PdfPTable(2);
ageTable.setWidthPercentage(50);
ageTable.setWidths(new float[]{1.5f, 1f});
addTableHeader(ageTable, "Altersgruppe", "Anzahl");
for (var entry : ageGroups.entrySet()) {
addDataRow(ageTable, entry.getKey(), String.valueOf(entry.getValue()));
}
document.add(ageTable);
document.add(Chunk.NEWLINE);
}
private void addSection3GrowOverview(Document document, List<GrowEntry> harvests) throws DocumentException {
addSectionHeader(document, "3. Anbauübersicht (§22 KCanG)");
if (harvests.isEmpty()) {
document.add(new Paragraph("Im Berichtszeitraum wurden keine Ernten verzeichnet.", NORMAL_FONT));
document.add(Chunk.NEWLINE);
return;
}
BigDecimal totalHarvest = harvests.stream()
.map(g -> g.getHarvestedGrams() != null ? g.getHarvestedGrams() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
long completedCycles = harvests.size();
document.add(new Paragraph("Gesamternte: " + formatGrams(totalHarvest) + " g", SUBSECTION_FONT));
document.add(new Paragraph("Abgeschlossene Anbauzyklen: " + completedCycles, NORMAL_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(4);
table.setWidthPercentage(90);
table.setWidths(new float[]{2.5f, 1.5f, 1.5f, 1.5f});
addTableHeader(table, "Bezeichnung", "Ernte (g)", "Erntedatum", "Status");
for (GrowEntry entry : harvests) {
String name = entry.getName() != null ? entry.getName() : "";
String grams = entry.getHarvestedGrams() != null ? formatGrams(entry.getHarvestedGrams()) : "0";
String harvestDate = entry.getActualHarvestAt() != null
? entry.getActualHarvestAt().atZone(ZoneId.of("Europe/Berlin")).toLocalDate().format(DATE_FMT)
: "";
String status = entry.getStatus() != null ? entry.getStatus().name() : "";
addDataRow(table, name, grams, harvestDate, status);
}
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection4DistributionStats(Document document, List<Distribution> distributions, List<Member> members) throws DocumentException {
addSectionHeader(document, "4. Weitergabe-Statistik (§19 KCanG)");
if (distributions.isEmpty()) {
document.add(new Paragraph("Im Berichtszeitraum fanden keine Weitergaben statt.", NORMAL_FONT));
document.add(Chunk.NEWLINE);
return;
}
BigDecimal totalGrams = distributions.stream()
.map(Distribution::getQuantityGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
long uniqueMembers = distributions.stream()
.map(Distribution::getMemberId)
.distinct()
.count();
BigDecimal avgPerMember = uniqueMembers > 0
? totalGrams.divide(BigDecimal.valueOf(uniqueMembers), 2, java.math.RoundingMode.HALF_UP)
: BigDecimal.ZERO;
long totalDistributions = distributions.size();
// U21 distributions
Set<UUID> under21Ids = members.stream()
.filter(Member::isUnder21)
.map(Member::getId)
.collect(Collectors.toSet());
long u21Distributions = distributions.stream()
.filter(d -> under21Ids.contains(d.getMemberId()))
.count();
PdfPTable statsTable = new PdfPTable(2);
statsTable.setWidthPercentage(70);
statsTable.setWidths(new float[]{2.5f, 1.5f});
addInfoRow(statsTable, "Gesamtanzahl Weitergaben", String.valueOf(totalDistributions));
addInfoRow(statsTable, "Gesamtmenge", formatGrams(totalGrams) + " g");
addInfoRow(statsTable, "Belieferte Mitglieder", String.valueOf(uniqueMembers));
addInfoRow(statsTable, "Durchschnitt pro Mitglied", formatGrams(avgPerMember) + " g");
addInfoRow(statsTable, "Weitergaben an U21-Mitglieder", String.valueOf(u21Distributions));
document.add(statsTable);
document.add(Chunk.NEWLINE);
}
private void addSection5StockManagement(Document document, List<GrowEntry> harvests,
List<Distribution> distributions,
List<DestructionRecord> destructions) throws DocumentException {
addSectionHeader(document, "5. Bestandsführung");
BigDecimal harvestTotal = harvests.stream()
.map(g -> g.getHarvestedGrams() != null ? g.getHarvestedGrams() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal distributedTotal = distributions.stream()
.map(Distribution::getQuantityGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal destroyedTotal = destructions.stream()
.map(DestructionRecord::getAmountGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal netChange = harvestTotal.subtract(distributedTotal).subtract(destroyedTotal);
PdfPTable table = new PdfPTable(2);
table.setWidthPercentage(70);
table.setWidths(new float[]{2.5f, 1.5f});
addInfoRow(table, "Zugänge (Ernte)", "+" + formatGrams(harvestTotal) + " g");
addInfoRow(table, "Abgänge (Weitergabe)", "" + formatGrams(distributedTotal) + " g");
addInfoRow(table, "Abgänge (Vernichtung)", "" + formatGrams(destroyedTotal) + " g");
// Separator
PdfPCell sepCell = new PdfPCell(new Phrase("─────────────────────────────", NORMAL_FONT));
sepCell.setColspan(2);
sepCell.setBorder(Rectangle.NO_BORDER);
table.addCell(sepCell);
addInfoRow(table, "Bestandsveränderung netto", formatGrams(netChange) + " g");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection6Destructions(Document document, List<DestructionRecord> destructions) throws DocumentException {
addSectionHeader(document, "6. Vernichtungsprotokolle");
if (destructions.isEmpty()) {
document.add(new Paragraph("Im Berichtszeitraum fanden keine Vernichtungen statt.", NORMAL_FONT));
document.add(Chunk.NEWLINE);
return;
}
BigDecimal totalDestroyed = destructions.stream()
.map(DestructionRecord::getAmountGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
document.add(new Paragraph("Anzahl Vernichtungsvorgänge: " + destructions.size(), NORMAL_FONT));
document.add(new Paragraph("Gesamtmenge vernichtet: " + formatGrams(totalDestroyed) + " g", NORMAL_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(4);
table.setWidthPercentage(90);
table.setWidths(new float[]{1.5f, 1.5f, 2f, 2f});
addTableHeader(table, "Datum", "Menge (g)", "Methode", "Grund");
for (DestructionRecord dr : destructions) {
String date = dr.getDestroyedAt().atZone(ZoneId.of("Europe/Berlin")).toLocalDate().format(DATE_FMT);
String amount = formatGrams(dr.getAmountGrams());
String method = dr.getDestructionMethod() != null ? dr.getDestructionMethod().name() : "";
String reason = dr.getDescription() != null ? truncate(dr.getDescription(), 40) : "";
addDataRow(table, date, amount, method, reason);
}
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection7Transports(Document document, List<TransportRecord> transports) throws DocumentException {
addSectionHeader(document, "7. Transportdokumentationen");
if (transports.isEmpty()) {
document.add(new Paragraph("Im Berichtszeitraum fanden keine Transporte statt.", NORMAL_FONT));
document.add(Chunk.NEWLINE);
return;
}
document.add(new Paragraph("Anzahl Transporte: " + transports.size(), NORMAL_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(5);
table.setWidthPercentage(95);
table.setWidths(new float[]{1.2f, 2f, 2f, 1f, 1.5f});
addTableHeader(table, "Datum", "Von", "Nach", "Menge (g)", "Status");
for (TransportRecord tr : transports) {
String date = tr.getTransportDate().format(DATE_FMT);
String from = truncate(tr.getFromLocation(), 30);
String to = truncate(tr.getToLocation(), 30);
String amount = formatGrams(tr.getAmountGrams());
String status = tr.getStatus() != null ? tr.getStatus().name() : "";
addDataRow(table, date, from, to, amount, status);
}
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection8Prevention(Document document, List<PreventionActivity> activities, List<Member> members) throws DocumentException {
addSectionHeader(document, "8. Präventionsmaßnahmen (§23 KCanG)");
// Find prevention officer
Optional<Member> officer = members.stream()
.filter(Member::isPreventionOfficer)
.findFirst();
if (officer.isPresent()) {
document.add(new Paragraph("Suchtpräventionsbeauftragter: "
+ officer.get().getFirstName() + " " + officer.get().getLastName(), NORMAL_FONT));
document.add(Chunk.NEWLINE);
}
if (activities.isEmpty()) {
document.add(new Paragraph("Im Berichtszeitraum wurden keine Präventionsmaßnahmen dokumentiert.", NORMAL_FONT));
document.add(Chunk.NEWLINE);
return;
}
document.add(new Paragraph("Durchgeführte Maßnahmen: " + activities.size(), NORMAL_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(3);
table.setWidthPercentage(90);
table.setWidths(new float[]{1.5f, 3f, 1.5f});
addTableHeader(table, "Datum", "Titel/Beschreibung", "Teilnehmer");
for (PreventionActivity pa : activities) {
String date = pa.getActivityDate().format(DATE_FMT);
String title = pa.getTitle() != null ? truncate(pa.getTitle(), 50) : "";
String participants = pa.getParticipantsCount() != null ? String.valueOf(pa.getParticipantsCount()) : "";
addDataRow(table, date, title, participants);
}
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection9YouthProtection(Document document, List<Distribution> distributions, List<Member> members) throws DocumentException {
addSectionHeader(document, "9. Jugendschutzmaßnahmen (§20 KCanG)");
Set<UUID> under21Ids = members.stream()
.filter(Member::isUnder21)
.map(Member::getId)
.collect(Collectors.toSet());
long u21Members = under21Ids.size();
long u21Distributions = distributions.stream()
.filter(d -> under21Ids.contains(d.getMemberId()))
.count();
BigDecimal u21Grams = distributions.stream()
.filter(d -> under21Ids.contains(d.getMemberId()))
.map(Distribution::getQuantityGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
PdfPTable table = new PdfPTable(2);
table.setWidthPercentage(70);
table.setWidths(new float[]{2.5f, 1.5f});
addInfoRow(table, "U21-Mitglieder gesamt", String.valueOf(u21Members));
addInfoRow(table, "Weitergaben an U21", String.valueOf(u21Distributions));
addInfoRow(table, "Gesamtmenge an U21", formatGrams(u21Grams) + " g");
addInfoRow(table, "THC-Limit U21 (max. 10%)", "Systemseitig geprüft");
addInfoRow(table, "Monatslimit U21 (max. 30g)", "Systemseitig geprüft");
addInfoRow(table, "Altersverifikation", "Bei jeder Weitergabe");
document.add(table);
document.add(Chunk.NEWLINE);
document.add(new Paragraph(
"Hinweis: Die Einhaltung der Weitergabegrenzen (§19 Abs. 3 KCanG: max. 30g/Monat, " +
"max. 10% THC für Mitglieder unter 21 Jahren) wird systemseitig bei jeder Weitergabe geprüft.",
SMALL_FONT));
document.add(Chunk.NEWLINE);
}
private void addFooter(Document document, Club club, int year) throws DocumentException {
Paragraph footer = new Paragraph();
footer.add(new Chunk("".repeat(80), FOOTER_FONT));
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("Anbauvereinigung gemäß §2 KCanG — " + club.getName()
+ " — Jahresbericht " + year, FOOTER_FONT));
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("Generiert am " + LocalDate.now().format(DATE_FMT)
+ " — Aufbewahrungspflicht: 5 Jahre (§24 KCanG)", FOOTER_FONT));
document.add(footer);
}
// === Utility methods ===
private void addSectionHeader(Document document, String title) throws DocumentException {
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph(title, SECTION_FONT);
document.add(p);
document.add(Chunk.NEWLINE);
}
private void addTableHeader(PdfPTable table, String... headers) {
for (String header : headers) {
PdfPCell cell = new PdfPCell(new Phrase(header, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
}
private void addDataRow(PdfPTable table, String... values) {
for (String value : values) {
PdfPCell cell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
cell.setPadding(4);
table.addCell(cell);
}
}
private void addInfoRow(PdfPTable table, String label, String value) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, TABLE_CELL_BOLD));
labelCell.setBorder(Rectangle.NO_BORDER);
labelCell.setPadding(4);
labelCell.setBackgroundColor(LIGHT_BG);
table.addCell(labelCell);
PdfPCell valueCell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
valueCell.setBorder(Rectangle.NO_BORDER);
valueCell.setPadding(4);
table.addCell(valueCell);
}
private String formatAddress(Club club) {
StringBuilder sb = new StringBuilder();
if (club.getAddressStreet() != null) sb.append(club.getAddressStreet()).append(", ");
if (club.getAddressPostalCode() != null) sb.append(club.getAddressPostalCode()).append(" ");
if (club.getAddressCity() != null) sb.append(club.getAddressCity());
return sb.toString().isEmpty() ? "" : sb.toString();
}
private String formatGrams(BigDecimal grams) {
if (grams == null) return "0,00";
return String.format(GERMAN, "%,.2f", grams);
}
private String truncate(String text, int maxLen) {
if (text == null) return "";
return text.length() > maxLen ? text.substring(0, maxLen - 1) + "" : text;
}
private int getAge(Member member, int year) {
if (member.getDateOfBirth() == null) return 0;
return year - member.getDateOfBirth().getYear();
}
}
@@ -0,0 +1,269 @@
package de.cannamanage.service.report;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.enums.AuditEventType;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.AuditService;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.MemberRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* THE HERO FEATURE: Full Authority Export (Behörden-Export).
* Generates a streaming ZIP file containing ALL compliance documents.
* Uses StreamingResponseBody pattern — entries are written sequentially
* without buffering the entire archive in heap memory.
*
* Security: Requires re-authentication + mandatory reason (audit trail).
* Rate limited: max 1 export per hour per tenant.
*/
@Service
public class AuthorityExportService {
private static final Logger log = LoggerFactory.getLogger(AuthorityExportService.class);
private static final DateTimeFormatter FILE_DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final AnnualAuthorityReportGenerator annualReportGenerator;
private final DistributionLogGenerator distributionLogGenerator;
private final DestructionProtocolGenerator destructionProtocolGenerator;
private final TransportCertificateGenerator transportCertificateGenerator;
private final BestandsfuehrungGenerator bestandsfuehrungGenerator;
private final PreventionActivityReportGenerator preventionActivityReportGenerator;
private final ClubRepository clubRepository;
private final MemberRepository memberRepository;
private final AuditService auditService;
// Rate limiting: track last export time per tenant
private final Map<UUID, Instant> lastExportTimes = new java.util.concurrent.ConcurrentHashMap<>();
public AuthorityExportService(AnnualAuthorityReportGenerator annualReportGenerator,
DistributionLogGenerator distributionLogGenerator,
DestructionProtocolGenerator destructionProtocolGenerator,
TransportCertificateGenerator transportCertificateGenerator,
BestandsfuehrungGenerator bestandsfuehrungGenerator,
PreventionActivityReportGenerator preventionActivityReportGenerator,
ClubRepository clubRepository,
MemberRepository memberRepository,
AuditService auditService) {
this.annualReportGenerator = annualReportGenerator;
this.distributionLogGenerator = distributionLogGenerator;
this.destructionProtocolGenerator = destructionProtocolGenerator;
this.transportCertificateGenerator = transportCertificateGenerator;
this.bestandsfuehrungGenerator = bestandsfuehrungGenerator;
this.preventionActivityReportGenerator = preventionActivityReportGenerator;
this.clubRepository = clubRepository;
this.memberRepository = memberRepository;
this.auditService = auditService;
}
/**
* Check rate limit: max 1 export per hour per tenant.
*/
public boolean isRateLimited(UUID tenantId) {
Instant lastExport = lastExportTimes.get(tenantId);
if (lastExport == null) return false;
return Instant.now().isBefore(lastExport.plusSeconds(3600));
}
/**
* Stream the authority export ZIP directly to the output stream.
* This avoids buffering the entire archive in memory — critical for large clubs.
*
* @param outputStream the HTTP response output stream
* @param clubId the club/tenant ID
* @param year the report year
* @param userId the requesting user (for audit)
* @param reason the mandatory reason (for audit trail)
*/
public void streamAuthorityExport(OutputStream outputStream, UUID clubId, int year,
UUID userId, String reason) throws IOException {
// Record rate limit
lastExportTimes.put(clubId, Instant.now());
// Audit log
auditService.log(
AuditEventType.AUTHORITY_EXPORT,
"Club", clubId, userId,
"System", "ADMIN",
"Behörden-Export generiert. Grund: " + reason + ". Jahr: " + year,
"{\"year\":" + year + ",\"reason\":\"" + escapeJson(reason) + "\"}",
null
);
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
LocalDate yearStart = LocalDate.of(year, 1, 1);
LocalDate yearEnd = LocalDate.of(year, 12, 31);
DateRangeReportParameters dateParams = new DateRangeReportParameters(yearStart, yearEnd, year);
YearReportParameters yearParams = new YearReportParameters(year);
String fromStr = yearStart.format(FILE_DATE_FMT);
String toStr = yearEnd.format(FILE_DATE_FMT);
try (ZipOutputStream zos = new ZipOutputStream(outputStream)) {
log.info("Starting authority export for club {} year {} by user {}", clubId, year, userId);
// 1. Annual Authority Report
writeZipEntry(zos, "01_Jahresbericht_" + year + ".pdf",
() -> annualReportGenerator.generatePdf(yearParams, clubId));
// 2. Distribution Log (PDF)
writeZipEntry(zos, "02_Ausgabeprotokoll_" + fromStr + "_" + toStr + ".pdf",
() -> distributionLogGenerator.generatePdf(dateParams, clubId));
// 3. Destruction Protocols
writeZipEntry(zos, "03_Vernichtungsprotokolle_" + fromStr + "_" + toStr + ".pdf",
() -> destructionProtocolGenerator.generatePdf(dateParams, clubId));
// 4. Transport Documentation
writeZipEntry(zos, "04_Transportdokumentation_" + fromStr + "_" + toStr + ".pdf",
() -> transportCertificateGenerator.generatePdf(dateParams, clubId));
// 5. Stock Management (Bestandsführung)
writeZipEntry(zos, "05_Bestandsfuehrung_" + fromStr + "_" + toStr + ".pdf",
() -> bestandsfuehrungGenerator.generatePdf(dateParams, clubId));
// 6. Prevention Activities
writeZipEntry(zos, "06_Praeventionsmassnahmen_" + year + ".pdf",
() -> preventionActivityReportGenerator.generatePdf(dateParams, clubId));
// 7. Anonymized Member List (JSON — numbers only, no names per DSGVO)
writeZipEntry(zos, "07_Mitgliederliste_anonymisiert.json",
() -> generateAnonymizedMemberList(clubId));
// 8. Index/Manifest (metadata)
writeZipEntry(zos, "index.json",
() -> generateManifest(club, year, reason));
zos.flush();
log.info("Authority export completed for club {} year {}", clubId, year);
}
}
/**
* Writes a single entry to the ZIP, generating content on-demand.
* Each PDF is generated and immediately written — not held in memory simultaneously.
*/
private void writeZipEntry(ZipOutputStream zos, String filename,
ContentSupplier contentSupplier) throws IOException {
try {
byte[] content = contentSupplier.get();
ZipEntry entry = new ZipEntry(filename);
entry.setSize(content.length);
zos.putNextEntry(entry);
zos.write(content);
zos.closeEntry();
log.debug("Written ZIP entry: {} ({} bytes)", filename, content.length);
} catch (Exception e) {
log.warn("Failed to generate {}: {}", filename, e.getMessage());
// Write error note instead of failing the entire export
String errorNote = "Fehler bei der Generierung: " + e.getMessage();
ZipEntry errorEntry = new ZipEntry(filename + ".error.txt");
byte[] errorBytes = errorNote.getBytes(StandardCharsets.UTF_8);
errorEntry.setSize(errorBytes.length);
zos.putNextEntry(errorEntry);
zos.write(errorBytes);
zos.closeEntry();
}
}
private byte[] generateAnonymizedMemberList(UUID clubId) {
List<Member> members = memberRepository.findByTenantId(clubId);
StringBuilder json = new StringBuilder();
json.append("{\n");
json.append(" \"hinweis\": \"Anonymisierte Mitgliederliste — nur Mitgliedsnummer und Geburtsjahr (DSGVO-Datenminimierung)\",\n");
json.append(" \"gesamt\": ").append(members.size()).append(",\n");
json.append(" \"mitglieder\": [\n");
for (int i = 0; i < members.size(); i++) {
Member m = members.get(i);
json.append(" {");
json.append("\"mitgliedsnummer\": \"").append(m.getMembershipNumber()).append("\", ");
json.append("\"geburtsjahr\": ").append(m.getDateOfBirth() != null ? m.getDateOfBirth().getYear() : "null").append(", ");
json.append("\"status\": \"").append(m.getStatus().name()).append("\", ");
json.append("\"beitritt\": \"").append(m.getMembershipDate()).append("\", ");
json.append("\"unter_21\": ").append(m.isUnder21());
json.append("}");
if (i < members.size() - 1) json.append(",");
json.append("\n");
}
json.append(" ]\n");
json.append("}\n");
return json.toString().getBytes(StandardCharsets.UTF_8);
}
private byte[] generateManifest(Club club, int year, String reason) {
String manifest = """
{
"format_version": "1.0",
"generated_at": "%s",
"club": {
"name": "%s",
"license_number": "%s",
"registration_number": "%s"
},
"period": {
"year": %d,
"from": "%d-01-01",
"to": "%d-12-31"
},
"reason": "%s",
"files": [
{"name": "01_Jahresbericht_%d.pdf", "description": "Jahresbericht gemäß §22 KCanG"},
{"name": "02_Ausgabeprotokoll_%d-01-01_%d-12-31.pdf", "description": "Ausgabeprotokoll gemäß §19 Abs. 4 KCanG"},
{"name": "03_Vernichtungsprotokolle_%d-01-01_%d-12-31.pdf", "description": "Vernichtungsprotokolle gemäß §22 KCanG"},
{"name": "04_Transportdokumentation_%d-01-01_%d-12-31.pdf", "description": "Transportdokumentation gemäß §22 Abs. 4 KCanG"},
{"name": "05_Bestandsfuehrung_%d-01-01_%d-12-31.pdf", "description": "Bestandsführung gemäß §22 KCanG"},
{"name": "06_Praeventionsmassnahmen_%d.pdf", "description": "Präventionsmaßnahmen gemäß §23 KCanG"},
{"name": "07_Mitgliederliste_anonymisiert.json", "description": "Anonymisierte Mitgliederliste (DSGVO)"}
],
"legal_basis": "§22, §24, §26, §27 KCanG",
"retention_period": "5 Jahre (§24 KCanG)"
}
""".formatted(
Instant.now().toString(),
escapeJson(club.getName()),
escapeJson(club.getLicenseNumber()),
club.getRegistrationNumber() != null ? escapeJson(club.getRegistrationNumber()) : "",
year, year, year,
escapeJson(reason),
year,
year, year,
year, year,
year, year,
year, year,
year
);
return manifest.getBytes(StandardCharsets.UTF_8);
}
private String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
@FunctionalInterface
private interface ContentSupplier {
byte[] get() throws Exception;
}
}
@@ -0,0 +1,395 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.DestructionRecord;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.domain.entity.GrowEntry;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.DestructionRecordRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.GrowEntryRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.io.OutputStreamWriter;
import java.math.BigDecimal;
import java.nio.charset.Charset;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Generates the Bestandsführung (Stock Management Report).
* Shows stock flow: Anfangsbestand + Zugänge (Ernte) - Ausgaben - Vernichtung = Endbestand.
* Per strain/batch breakdown. Supports PDF + CSV.
*/
@Service
public class BestandsfuehrungGenerator implements ReportGenerator<DateRangeReportParameters> {
private static final Logger log = LoggerFactory.getLogger(BestandsfuehrungGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, 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, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.ITALIC, Color.GRAY);
private static final Font TOTAL_FONT = new Font(Font.HELVETICA, 11, Font.BOLD);
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1");
private static final Locale GERMAN = Locale.GERMANY;
private final ClubRepository clubRepository;
private final GrowEntryRepository growEntryRepository;
private final DistributionRepository distributionRepository;
private final DestructionRecordRepository destructionRecordRepository;
private final BatchRepository batchRepository;
public BestandsfuehrungGenerator(ClubRepository clubRepository,
GrowEntryRepository growEntryRepository,
DistributionRepository distributionRepository,
DestructionRecordRepository destructionRecordRepository,
BatchRepository batchRepository) {
this.clubRepository = clubRepository;
this.growEntryRepository = growEntryRepository;
this.distributionRepository = distributionRepository;
this.destructionRecordRepository = destructionRecordRepository;
this.batchRepository = batchRepository;
}
@Override
public ReportType getType() {
return ReportType.STOCK_INVENTORY;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF, ExportFormat.CSV);
}
@Override
public byte[] generatePdf(DateRangeReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
StockFlowData data = calculateStockFlow(params, clubId);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
// Header
Paragraph header = new Paragraph("BESTANDSFÜHRUNG", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("Bestandsbewegungen gemäß §22 KCanG", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
// Info
Paragraph info = new Paragraph();
info.add(new Chunk("Verein: ", SUBSECTION_FONT));
info.add(new Chunk(club.getName(), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Zeitraum: ", SUBSECTION_FONT));
info.add(new Chunk(params.from().format(DATE_FMT) + " " + params.to().format(DATE_FMT), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Erstellt: ", SUBSECTION_FONT));
info.add(new Chunk(LocalDate.now().format(DATE_FMT), NORMAL_FONT));
document.add(info);
document.add(Chunk.NEWLINE);
// Summary table
document.add(new Paragraph("Bestandsübersicht:", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable summaryTable = new PdfPTable(2);
summaryTable.setWidthPercentage(60);
summaryTable.setWidths(new float[]{2.5f, 1.5f});
addInfoRow(summaryTable, "Zugänge (Ernte)", "+" + formatGrams(data.totalHarvested) + " g");
addInfoRow(summaryTable, "Abgänge (Weitergabe)", "" + formatGrams(data.totalDistributed) + " g");
addInfoRow(summaryTable, "Abgänge (Vernichtung)", "" + formatGrams(data.totalDestroyed) + " g");
// Separator
PdfPCell sepCell = new PdfPCell(new Phrase("═══════════════════════════════", TABLE_CELL_FONT));
sepCell.setColspan(2);
sepCell.setBorder(Rectangle.NO_BORDER);
summaryTable.addCell(sepCell);
BigDecimal netChange = data.totalHarvested.subtract(data.totalDistributed).subtract(data.totalDestroyed);
addInfoRow(summaryTable, "Bestandsveränderung netto",
(netChange.signum() >= 0 ? "+" : "") + formatGrams(netChange) + " g");
document.add(summaryTable);
document.add(Chunk.NEWLINE);
// Batch-level detail table
if (!data.batchMovements.isEmpty()) {
document.add(new Paragraph("Detail nach Charge:", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable batchTable = new PdfPTable(5);
batchTable.setWidthPercentage(95);
batchTable.setWidths(new float[]{2f, 1.5f, 1.5f, 1.5f, 1.5f});
addTableHeader(batchTable, "Charge", "Bestand (g)", "Verteilt (g)", "Vernichtet (g)", "Saldo (g)");
int rowIdx = 0;
for (BatchMovement bm : data.batchMovements) {
boolean alternate = rowIdx % 2 == 1;
String saldo = formatGrams(bm.stock.subtract(bm.distributed).subtract(bm.destroyed));
addDataRow(batchTable, alternate,
bm.batchCode,
formatGrams(bm.stock),
formatGrams(bm.distributed),
formatGrams(bm.destroyed),
saldo);
rowIdx++;
}
document.add(batchTable);
}
// Footer
document.add(Chunk.NEWLINE);
document.add(new Paragraph("".repeat(80), FOOTER_FONT));
document.add(new Paragraph(
"Anbauvereinigung gemäß §2 KCanG — " + club.getName()
+ " — Generiert am " + LocalDate.now().format(DATE_FMT),
FOOTER_FONT));
document.close();
log.info("Generated Bestandsführung for club {}", clubId);
return baos.toByteArray();
} catch (Exception e) {
log.error("Failed to generate Bestandsführung PDF for club {}", clubId, e);
throw new RuntimeException("PDF generation failed", e);
}
}
@Override
public byte[] generateCsv(DateRangeReportParameters params, UUID clubId) {
StockFlowData data = calculateStockFlow(params, clubId);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(baos, ISO_8859_1)) {
// Header
writer.write("Charge;Bestand_g;Verteilt_g;Vernichtet_g;Saldo_g\n");
for (BatchMovement bm : data.batchMovements) {
BigDecimal saldo = bm.stock.subtract(bm.distributed).subtract(bm.destroyed);
writer.write(String.join(";",
bm.batchCode,
formatGramsCsv(bm.stock),
formatGramsCsv(bm.distributed),
formatGramsCsv(bm.destroyed),
formatGramsCsv(saldo)));
writer.write("\n");
}
// Totals
BigDecimal netChange = data.totalHarvested.subtract(data.totalDistributed).subtract(data.totalDestroyed);
writer.write(String.join(";",
"GESAMT",
formatGramsCsv(data.totalHarvested),
formatGramsCsv(data.totalDistributed),
formatGramsCsv(data.totalDestroyed),
formatGramsCsv(netChange)));
writer.write("\n");
writer.flush();
return baos.toByteArray();
} catch (Exception e) {
log.error("Failed to generate Bestandsführung CSV for club {}", clubId, e);
throw new RuntimeException("CSV generation failed", e);
}
}
// === Data calculation ===
private StockFlowData calculateStockFlow(DateRangeReportParameters params, UUID clubId) {
Instant start = params.from().atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
Instant end = params.to().atTime(23, 59, 59).atZone(ZoneId.of("Europe/Berlin")).toInstant();
List<Distribution> distributions = distributionRepository.findByTenantIdAndDistributedAtBetween(clubId, start, end);
List<DestructionRecord> destructions = destructionRecordRepository
.findByClubIdAndDestroyedAtBetweenOrderByDestroyedAtAsc(clubId, start, end);
List<GrowEntry> growEntries = growEntryRepository.findAllByOrderByStartedAtDesc().stream()
.filter(g -> g.getActualHarvestAt() != null)
.filter(g -> {
Instant harvestAt = g.getActualHarvestAt();
return !harvestAt.isBefore(start) && !harvestAt.isAfter(end);
})
.collect(Collectors.toList());
// Build batch map
Map<UUID, Batch> batchMap = batchRepository.findAll().stream()
.collect(Collectors.toMap(Batch::getId, b -> b, (a, b) -> a));
// Calculate totals
BigDecimal totalHarvested = growEntries.stream()
.map(g -> g.getHarvestedGrams() != null ? g.getHarvestedGrams() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalDistributed = distributions.stream()
.map(Distribution::getQuantityGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalDestroyed = destructions.stream()
.map(DestructionRecord::getAmountGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Per-batch movements
Map<UUID, BatchMovement> movements = new LinkedHashMap<>();
// Add batch stock from batches table
for (Batch batch : batchMap.values()) {
BatchMovement bm = movements.computeIfAbsent(batch.getId(),
id -> new BatchMovement(batch.getBatchCode(), batch.getQuantityGrams(), BigDecimal.ZERO, BigDecimal.ZERO));
}
// Add distributions per batch
for (Distribution dist : distributions) {
if (dist.getBatchId() != null) {
Batch batch = batchMap.get(dist.getBatchId());
String code = batch != null ? batch.getBatchCode() : dist.getBatchId().toString().substring(0, 8);
BatchMovement bm = movements.computeIfAbsent(dist.getBatchId(),
id -> new BatchMovement(code, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO));
bm.distributed = bm.distributed.add(dist.getQuantityGrams());
}
}
// Add destructions per batch
for (DestructionRecord dr : destructions) {
if (dr.getBatchId() != null) {
Batch batch = batchMap.get(dr.getBatchId());
String code = batch != null ? batch.getBatchCode() : dr.getBatchId().toString().substring(0, 8);
BatchMovement bm = movements.computeIfAbsent(dr.getBatchId(),
id -> new BatchMovement(code, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO));
bm.destroyed = bm.destroyed.add(dr.getAmountGrams());
}
}
// Filter out batches with no activity
List<BatchMovement> activeBatches = movements.values().stream()
.filter(bm -> bm.distributed.signum() > 0 || bm.destroyed.signum() > 0 || bm.stock.signum() > 0)
.collect(Collectors.toList());
return new StockFlowData(totalHarvested, totalDistributed, totalDestroyed, activeBatches);
}
// === Inner classes ===
private static class StockFlowData {
final BigDecimal totalHarvested;
final BigDecimal totalDistributed;
final BigDecimal totalDestroyed;
final List<BatchMovement> batchMovements;
StockFlowData(BigDecimal totalHarvested, BigDecimal totalDistributed,
BigDecimal totalDestroyed, List<BatchMovement> batchMovements) {
this.totalHarvested = totalHarvested;
this.totalDistributed = totalDistributed;
this.totalDestroyed = totalDestroyed;
this.batchMovements = batchMovements;
}
}
private static class BatchMovement {
final String batchCode;
final BigDecimal stock;
BigDecimal distributed;
BigDecimal destroyed;
BatchMovement(String batchCode, BigDecimal stock, BigDecimal distributed, BigDecimal destroyed) {
this.batchCode = batchCode;
this.stock = stock;
this.distributed = distributed;
this.destroyed = destroyed;
}
}
// === Utility methods ===
private void addTableHeader(PdfPTable table, String... headers) {
for (String header : headers) {
PdfPCell cell = new PdfPCell(new Phrase(header, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(4);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
}
private void addDataRow(PdfPTable table, boolean alternate, String... values) {
for (String value : values) {
PdfPCell cell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
cell.setPadding(3);
if (alternate) cell.setBackgroundColor(LIGHT_BG);
table.addCell(cell);
}
}
private void addInfoRow(PdfPTable table, String label, String value) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, TABLE_CELL_BOLD));
labelCell.setBorder(Rectangle.NO_BORDER);
labelCell.setPadding(4);
labelCell.setBackgroundColor(LIGHT_BG);
table.addCell(labelCell);
PdfPCell valueCell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
valueCell.setBorder(Rectangle.NO_BORDER);
valueCell.setPadding(4);
table.addCell(valueCell);
}
private String formatGrams(BigDecimal grams) {
if (grams == null) return "0,00";
return String.format(GERMAN, "%,.2f", grams);
}
private String formatGramsCsv(BigDecimal grams) {
if (grams == null) return "0,00";
return String.format(GERMAN, "%.2f", grams);
}
}
@@ -0,0 +1,260 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
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.domain.entity.DestructionRecord;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.DestructionRecordRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
/**
* Generates Destruction Protocol per §22 KCanG.
* Official Vernichtungsprotokoll suitable for authority submission.
* PDF format with dual signature lines and sequential numbering.
*/
@Service
public class DestructionProtocolGenerator implements ReportGenerator<DateRangeReportParameters> {
private static final Logger log = LoggerFactory.getLogger(DestructionProtocolGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, 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, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.ITALIC, Color.GRAY);
private static final Font SIGNATURE_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL, Color.DARK_GRAY);
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
private static final Locale GERMAN = Locale.GERMANY;
private final DestructionRecordRepository destructionRecordRepository;
private final ClubRepository clubRepository;
public DestructionProtocolGenerator(DestructionRecordRepository destructionRecordRepository,
ClubRepository clubRepository) {
this.destructionRecordRepository = destructionRecordRepository;
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.DESTRUCTION_PROTOCOL;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(DateRangeReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
Instant start = params.from().atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
Instant end = params.to().atTime(23, 59, 59).atZone(ZoneId.of("Europe/Berlin")).toInstant();
List<DestructionRecord> records = destructionRecordRepository
.findByClubIdAndDestroyedAtBetweenOrderByDestroyedAtAsc(clubId, start, end);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
// Header
Paragraph header = new Paragraph("VERNICHTUNGSPROTOKOLL", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("gemäß §22 KCanG", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
// Club info
Paragraph clubInfo = new Paragraph();
clubInfo.add(new Chunk("Anbauvereinigung: ", SUBSECTION_FONT));
clubInfo.add(new Chunk(club.getName(), NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
clubInfo.add(new Chunk("Erlaubnisnummer: ", SUBSECTION_FONT));
clubInfo.add(new Chunk(club.getLicenseNumber(), NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
clubInfo.add(new Chunk("Zeitraum: ", SUBSECTION_FONT));
clubInfo.add(new Chunk(params.from().format(DATE_FMT) + " " + params.to().format(DATE_FMT), NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
clubInfo.add(new Chunk("Erstellt am: ", SUBSECTION_FONT));
clubInfo.add(new Chunk(LocalDate.now().format(DATE_FMT), NORMAL_FONT));
document.add(clubInfo);
document.add(Chunk.NEWLINE);
if (records.isEmpty()) {
document.add(new Paragraph("Im angegebenen Zeitraum wurden keine Vernichtungen dokumentiert.", NORMAL_FONT));
} else {
// Summary
BigDecimal totalDestroyed = records.stream()
.map(DestructionRecord::getAmountGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
document.add(new Paragraph("Zusammenfassung:", SECTION_FONT));
document.add(new Paragraph("Anzahl Vernichtungsvorgänge: " + records.size(), NORMAL_FONT));
document.add(new Paragraph("Gesamtmenge vernichtet: " + formatGrams(totalDestroyed) + " g", NORMAL_FONT));
document.add(Chunk.NEWLINE);
// Detail table
document.add(new Paragraph("Einzelprotokolle:", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(7);
table.setWidthPercentage(100);
table.setWidths(new float[]{0.5f, 1.2f, 1f, 1.5f, 1.5f, 1.5f, 1.5f});
addTableHeader(table, "Nr.", "Datum", "Menge (g)", "Methode", "Grund", "Zeuge", "Verantwortlich");
int protocolNr = 1;
for (DestructionRecord record : records) {
String nr = String.valueOf(protocolNr++);
String date = record.getDestroyedAt().atZone(ZoneId.of("Europe/Berlin")).format(DATETIME_FMT);
String amount = formatGrams(record.getAmountGrams());
String method = formatMethod(record.getDestructionMethod().name());
String reason = record.getDescription() != null ? truncate(record.getDescription(), 30) : "";
String witness = record.getWitnessName() != null ? record.getWitnessName() : "";
String responsible = "Dokumentiert"; // recordedBy is UUID, not resolved to name
boolean alternate = (protocolNr % 2) == 0;
addDataRow(table, alternate, nr, date, amount, method, reason, witness, responsible);
}
document.add(table);
}
// Signature section
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
document.add(new Paragraph("Unterschriften:", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable sigTable = new PdfPTable(2);
sigTable.setWidthPercentage(80);
sigTable.setWidths(new float[]{1f, 1f});
addSignatureLine(sigTable, "Vernichtungsverantwortlicher");
addSignatureLine(sigTable, "Zeuge");
document.add(sigTable);
// Footer
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
document.add(new Paragraph("".repeat(80), FOOTER_FONT));
document.add(new Paragraph(
"Vernichtungsprotokoll gemäß §22 KCanG. Aufbewahrungspflicht: 5 Jahre (§24 KCanG).",
FOOTER_FONT));
document.add(new Paragraph(
"Anbauvereinigung gemäß §2 KCanG — " + club.getName() + " — Generiert am " + LocalDate.now().format(DATE_FMT),
FOOTER_FONT));
document.close();
log.info("Generated Destruction Protocol for club {} with {} records", clubId, records.size());
return baos.toByteArray();
} catch (Exception e) {
log.error("Failed to generate Destruction Protocol PDF for club {}", clubId, e);
throw new RuntimeException("PDF generation failed", e);
}
}
// === Utility methods ===
private void addTableHeader(PdfPTable table, String... headers) {
for (String header : headers) {
PdfPCell cell = new PdfPCell(new Phrase(header, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(4);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
}
private void addDataRow(PdfPTable table, boolean alternate, String... values) {
for (String value : values) {
PdfPCell cell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
cell.setPadding(3);
if (alternate) cell.setBackgroundColor(LIGHT_BG);
table.addCell(cell);
}
}
private void addSignatureLine(PdfPTable table, String label) {
PdfPCell cell = new PdfPCell();
cell.setBorder(Rectangle.NO_BORDER);
cell.setPadding(10);
Paragraph p = new Paragraph();
p.add(Chunk.NEWLINE);
p.add(Chunk.NEWLINE);
p.add(new Chunk("_______________________________", SIGNATURE_FONT));
p.add(Chunk.NEWLINE);
p.add(new Chunk(label, SIGNATURE_FONT));
p.add(Chunk.NEWLINE);
p.add(new Chunk("Datum, Unterschrift", FOOTER_FONT));
cell.addElement(p);
table.addCell(cell);
}
private String formatGrams(BigDecimal grams) {
if (grams == null) return "0,00";
return String.format(GERMAN, "%,.2f", grams);
}
private String formatMethod(String method) {
return switch (method) {
case "INCINERATION" -> "Verbrennung";
case "COMPOSTING" -> "Kompostierung";
case "CHEMICAL" -> "Chemisch";
default -> "Sonstige";
};
}
private String truncate(String text, int maxLen) {
if (text == null) return "";
return text.length() > maxLen ? text.substring(0, maxLen - 1) + "" : text;
}
}
@@ -0,0 +1,278 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.io.OutputStreamWriter;
import java.math.BigDecimal;
import java.nio.charset.Charset;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Generates the Distribution Log per §19(4) KCanG.
* Complete distribution record for a period with anonymized member data (DSGVO).
* Supports PDF + CSV formats.
*/
@Service
public class DistributionLogGenerator implements ReportGenerator<DateRangeReportParameters> {
private static final Logger log = LoggerFactory.getLogger(DistributionLogGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font SECTION_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 SMALL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 8, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 8, Font.BOLD);
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.ITALIC, Color.GRAY);
private static final Font TOTAL_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1");
private static final Locale GERMAN = Locale.GERMANY;
private final DistributionRepository distributionRepository;
private final MemberRepository memberRepository;
private final BatchRepository batchRepository;
private final ClubRepository clubRepository;
public DistributionLogGenerator(DistributionRepository distributionRepository,
MemberRepository memberRepository,
BatchRepository batchRepository,
ClubRepository clubRepository) {
this.distributionRepository = distributionRepository;
this.memberRepository = memberRepository;
this.batchRepository = batchRepository;
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.DISTRIBUTION_LOG;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF, ExportFormat.CSV);
}
@Override
public byte[] generatePdf(DateRangeReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
Instant start = params.from().atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
Instant end = params.to().atTime(23, 59, 59).atZone(ZoneId.of("Europe/Berlin")).toInstant();
List<Distribution> distributions = distributionRepository.findByTenantIdAndDistributedAtBetween(clubId, start, end);
Map<UUID, Member> memberMap = memberRepository.findByTenantId(clubId).stream()
.collect(Collectors.toMap(Member::getId, m -> m));
Map<UUID, Batch> batchMap = batchRepository.findAll().stream()
.collect(Collectors.toMap(Batch::getId, b -> b, (a, b) -> a));
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4.rotate(), 40, 40, 40, 40); // Landscape
PdfWriter.getInstance(document, baos);
document.open();
// Header
Paragraph header = new Paragraph("AUSGABEPROTOKOLL", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("gemäß §19 Abs. 4 KCanG", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
// Club + period info
Paragraph info = new Paragraph();
info.add(new Chunk("Verein: ", TABLE_CELL_BOLD));
info.add(new Chunk(club.getName(), NORMAL_FONT));
info.add(new Chunk(" Zeitraum: ", TABLE_CELL_BOLD));
info.add(new Chunk(params.from().format(DATE_FMT) + " " + params.to().format(DATE_FMT), NORMAL_FONT));
info.add(new Chunk(" Erstellt: ", TABLE_CELL_BOLD));
info.add(new Chunk(LocalDate.now().format(DATE_FMT), NORMAL_FONT));
document.add(info);
document.add(Chunk.NEWLINE);
// Distribution table
PdfPTable table = new PdfPTable(6);
table.setWidthPercentage(100);
table.setWidths(new float[]{1.5f, 1.5f, 2f, 1f, 1.5f, 1.5f});
addTableHeader(table, "Datum", "Mitglied", "Sorte/Charge", "Menge (g)", "THC%", "Ausgabe-Personal");
BigDecimal totalGrams = BigDecimal.ZERO;
int rowIdx = 0;
for (Distribution dist : distributions) {
boolean alternate = rowIdx % 2 == 1;
String date = dist.getDistributedAt().atZone(ZoneId.of("Europe/Berlin")).format(DATETIME_FMT);
// Anonymized member reference
Member member = memberMap.get(dist.getMemberId());
String memberRef = member != null ? "M-" + member.getMembershipNumber() : "M-???";
// Batch info
Batch batch = batchMap.get(dist.getBatchId());
String batchInfo = batch != null ? batch.getBatchCode() : "";
String amount = formatGrams(dist.getQuantityGrams());
String thc = ""; // THC% not stored on Distribution entity yet
String recordedBy = "Personal"; // Staff ID not resolved to name for privacy
addDataRow(table, alternate, date, memberRef, batchInfo, amount, thc, recordedBy);
totalGrams = totalGrams.add(dist.getQuantityGrams());
rowIdx++;
}
document.add(table);
document.add(Chunk.NEWLINE);
// Totals
Paragraph totals = new Paragraph();
totals.add(new Chunk("Gesamt: ", TOTAL_FONT));
totals.add(new Chunk(distributions.size() + " Weitergaben, " + formatGrams(totalGrams) + " g", SECTION_FONT));
document.add(totals);
long uniqueMembers = distributions.stream().map(Distribution::getMemberId).distinct().count();
BigDecimal avg = uniqueMembers > 0
? totalGrams.divide(BigDecimal.valueOf(uniqueMembers), 2, java.math.RoundingMode.HALF_UP)
: BigDecimal.ZERO;
document.add(new Paragraph("Belieferte Mitglieder: " + uniqueMembers
+ " | Durchschnitt pro Mitglied: " + formatGrams(avg) + " g", NORMAL_FONT));
// Footer
document.add(Chunk.NEWLINE);
document.add(new Paragraph("".repeat(100), FOOTER_FONT));
document.add(new Paragraph("Anbauvereinigung gemäß §2 KCanG — " + club.getName()
+ " — Generiert am " + LocalDate.now().format(DATE_FMT), FOOTER_FONT));
document.add(new Paragraph("Hinweis: Mitgliederdaten anonymisiert (nur Mitgliedsnummer) gemäß DSGVO-Datenminimierung.", FOOTER_FONT));
document.close();
return baos.toByteArray();
} catch (Exception e) {
log.error("Failed to generate Distribution Log PDF for club {}", clubId, e);
throw new RuntimeException("PDF generation failed", e);
}
}
@Override
public byte[] generateCsv(DateRangeReportParameters params, UUID clubId) {
Instant start = params.from().atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
Instant end = params.to().atTime(23, 59, 59).atZone(ZoneId.of("Europe/Berlin")).toInstant();
List<Distribution> distributions = distributionRepository.findByTenantIdAndDistributedAtBetween(clubId, start, end);
Map<UUID, Member> memberMap = memberRepository.findByTenantId(clubId).stream()
.collect(Collectors.toMap(Member::getId, m -> m));
Map<UUID, Batch> batchMap = batchRepository.findAll().stream()
.collect(Collectors.toMap(Batch::getId, b -> b, (a, b) -> a));
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(baos, ISO_8859_1)) {
// Header
writer.write("Datum;Mitglied;Sorte/Charge;Menge_g;THC_Prozent;Ausgabe_Personal\n");
BigDecimal totalGrams = BigDecimal.ZERO;
for (Distribution dist : distributions) {
String date = dist.getDistributedAt().atZone(ZoneId.of("Europe/Berlin")).format(DATETIME_FMT);
Member member = memberMap.get(dist.getMemberId());
String memberRef = member != null ? "M-" + member.getMembershipNumber() : "M-???";
Batch batch = batchMap.get(dist.getBatchId());
String batchInfo = batch != null ? batch.getBatchCode() : "";
String amount = formatGramsCsv(dist.getQuantityGrams());
writer.write(String.join(";", date, memberRef, batchInfo, amount, "", "Personal"));
writer.write("\n");
totalGrams = totalGrams.add(dist.getQuantityGrams());
}
// Totals row
writer.write(String.join(";", "GESAMT", "", "", formatGramsCsv(totalGrams), "", ""));
writer.write("\n");
writer.flush();
return baos.toByteArray();
} catch (Exception e) {
log.error("Failed to generate Distribution Log CSV for club {}", clubId, e);
throw new RuntimeException("CSV generation failed", e);
}
}
// === Utility methods ===
private void addTableHeader(PdfPTable table, String... headers) {
for (String header : headers) {
PdfPCell cell = new PdfPCell(new Phrase(header, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(4);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
}
private void addDataRow(PdfPTable table, boolean alternate, String... values) {
for (String value : values) {
PdfPCell cell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
cell.setPadding(3);
if (alternate) cell.setBackgroundColor(LIGHT_BG);
table.addCell(cell);
}
}
private String formatGrams(BigDecimal grams) {
if (grams == null) return "0,00";
return String.format(GERMAN, "%,.2f", grams);
}
private String formatGramsCsv(BigDecimal grams) {
if (grams == null) return "0,00";
return String.format(GERMAN, "%.2f", grams);
}
}
@@ -0,0 +1,224 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
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.domain.entity.Member;
import de.cannamanage.domain.entity.PreventionActivity;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.PreventionActivityRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
/**
* Generates Prevention Activities Report per §23 KCanG.
* Documents all Suchtprävention measures conducted by the Anbauvereinigung.
*/
@Service
public class PreventionActivityReportGenerator implements ReportGenerator<DateRangeReportParameters> {
private static final Logger log = LoggerFactory.getLogger(PreventionActivityReportGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, 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, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.ITALIC, Color.GRAY);
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final PreventionActivityRepository preventionActivityRepository;
private final MemberRepository memberRepository;
private final ClubRepository clubRepository;
public PreventionActivityReportGenerator(PreventionActivityRepository preventionActivityRepository,
MemberRepository memberRepository,
ClubRepository clubRepository) {
this.preventionActivityRepository = preventionActivityRepository;
this.memberRepository = memberRepository;
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
// Uses CULTIVATION_REPORT slot as "prevention" — or could add a new enum.
// For authority export, this is invoked directly, not via ReportGeneratorService.
return ReportType.CULTIVATION_REPORT; // Placeholder — not registered as primary
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(DateRangeReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
List<PreventionActivity> activities = preventionActivityRepository
.findByClubIdAndActivityDateBetween(clubId, params.from(), params.to());
// Find prevention officer(s)
List<Member> officers = memberRepository.findByTenantId(clubId).stream()
.filter(Member::isPreventionOfficer)
.toList();
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
// Header
Paragraph header = new Paragraph("PRÄVENTIONSMASSNAHMEN", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("gemäß §23 KCanG — Sucht- und Präventionsberatung", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
// Club info
Paragraph clubInfo = new Paragraph();
clubInfo.add(new Chunk("Anbauvereinigung: ", SUBSECTION_FONT));
clubInfo.add(new Chunk(club.getName(), NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
clubInfo.add(new Chunk("Zeitraum: ", SUBSECTION_FONT));
clubInfo.add(new Chunk(params.from().format(DATE_FMT) + " " + params.to().format(DATE_FMT), NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
clubInfo.add(new Chunk("Erstellt: ", SUBSECTION_FONT));
clubInfo.add(new Chunk(LocalDate.now().format(DATE_FMT), NORMAL_FONT));
document.add(clubInfo);
document.add(Chunk.NEWLINE);
// Prevention officer info
document.add(new Paragraph("Suchtpräventionsbeauftragte:", SECTION_FONT));
if (officers.isEmpty()) {
document.add(new Paragraph("⚠ Kein Suchtpräventionsbeauftragter benannt (Pflicht gem. §23 KCanG).", NORMAL_FONT));
} else {
for (Member officer : officers) {
document.add(new Paragraph("" + officer.getFirstName() + " " + officer.getLastName(), NORMAL_FONT));
}
}
document.add(Chunk.NEWLINE);
// Activities
document.add(new Paragraph("Durchgeführte Maßnahmen (" + activities.size() + "):", SECTION_FONT));
document.add(Chunk.NEWLINE);
if (activities.isEmpty()) {
document.add(new Paragraph("Im Berichtszeitraum wurden keine Präventionsmaßnahmen dokumentiert.", NORMAL_FONT));
} else {
PdfPTable table = new PdfPTable(4);
table.setWidthPercentage(100);
table.setWidths(new float[]{1.2f, 2.5f, 3f, 1f});
addTableHeader(table, "Datum", "Titel", "Beschreibung", "Teilnehmer");
int totalParticipants = 0;
int rowIdx = 0;
for (PreventionActivity activity : activities) {
boolean alternate = rowIdx % 2 == 1;
String date = activity.getActivityDate().format(DATE_FMT);
String title = activity.getTitle() != null ? activity.getTitle() : "";
String desc = activity.getDescription() != null ? truncate(activity.getDescription(), 60) : "";
String participants = activity.getParticipantsCount() != null
? String.valueOf(activity.getParticipantsCount()) : "";
addDataRow(table, alternate, date, title, desc, participants);
if (activity.getParticipantsCount() != null) {
totalParticipants += activity.getParticipantsCount();
}
rowIdx++;
}
document.add(table);
document.add(Chunk.NEWLINE);
// Summary
document.add(new Paragraph("Zusammenfassung:", SUBSECTION_FONT));
document.add(new Paragraph("• Durchgeführte Maßnahmen: " + activities.size(), NORMAL_FONT));
document.add(new Paragraph("• Gesamtteilnehmer: " + totalParticipants, NORMAL_FONT));
}
// Footer
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
document.add(new Paragraph("".repeat(80), FOOTER_FONT));
document.add(new Paragraph(
"Anbauvereinigung gemäß §2 KCanG — " + club.getName()
+ " — Generiert am " + LocalDate.now().format(DATE_FMT),
FOOTER_FONT));
document.add(new Paragraph(
"Präventionsmaßnahmen gemäß §23 KCanG. Aufbewahrungspflicht: 5 Jahre (§24 KCanG).",
FOOTER_FONT));
document.close();
log.info("Generated Prevention Activity Report for club {} with {} activities", clubId, activities.size());
return baos.toByteArray();
} catch (Exception e) {
log.error("Failed to generate Prevention Activity Report for club {}", clubId, e);
throw new RuntimeException("PDF generation failed", e);
}
}
// === Utility methods ===
private void addTableHeader(PdfPTable table, String... headers) {
for (String header : headers) {
PdfPCell cell = new PdfPCell(new Phrase(header, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(4);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
}
private void addDataRow(PdfPTable table, boolean alternate, String... values) {
for (String value : values) {
PdfPCell cell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
cell.setPadding(3);
if (alternate) cell.setBackgroundColor(LIGHT_BG);
table.addCell(cell);
}
}
private String truncate(String text, int maxLen) {
if (text == null) return "";
return text.length() > maxLen ? text.substring(0, maxLen - 1) + "" : text;
}
}
@@ -0,0 +1,223 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
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.domain.entity.TransportRecord;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.TransportRecordRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
/**
* Generates Transport Certificate per §22 Abs. 4 KCanG.
* Single-page PDF suitable for printing and carrying during transport.
*/
@Service
public class TransportCertificateGenerator implements ReportGenerator<DateRangeReportParameters> {
private static final Logger log = LoggerFactory.getLogger(TransportCertificateGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, 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, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.ITALIC, Color.GRAY);
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final Locale GERMAN = Locale.GERMANY;
private final TransportRecordRepository transportRecordRepository;
private final ClubRepository clubRepository;
public TransportCertificateGenerator(TransportRecordRepository transportRecordRepository,
ClubRepository clubRepository) {
this.transportRecordRepository = transportRecordRepository;
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.TRANSPORT_CERTIFICATE;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(DateRangeReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
List<TransportRecord> records = transportRecordRepository
.findByClubIdAndTransportDateBetweenOrderByTransportDateAsc(
clubId, params.from(), params.to());
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
// Header
Paragraph header = new Paragraph("TRANSPORTDOKUMENTATION", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("gemäß §22 Abs. 4 KCanG", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
// Club info per §22(4) requirement
document.add(new Paragraph("1. Name und Anschrift der Anbauvereinigung:", SUBSECTION_FONT));
Paragraph clubInfo = new Paragraph();
clubInfo.add(new Chunk(club.getName(), NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
if (club.getAddressStreet() != null) {
clubInfo.add(new Chunk(club.getAddressStreet(), NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
}
if (club.getAddressPostalCode() != null || club.getAddressCity() != null) {
String addr = (club.getAddressPostalCode() != null ? club.getAddressPostalCode() + " " : "")
+ (club.getAddressCity() != null ? club.getAddressCity() : "");
clubInfo.add(new Chunk(addr, NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
}
clubInfo.add(new Chunk("Erlaubnisnummer: " + club.getLicenseNumber(), NORMAL_FONT));
document.add(clubInfo);
document.add(Chunk.NEWLINE);
// Period
document.add(new Paragraph("Zeitraum: " + params.from().format(DATE_FMT)
+ " " + params.to().format(DATE_FMT), SUBSECTION_FONT));
document.add(Chunk.NEWLINE);
if (records.isEmpty()) {
document.add(new Paragraph("Keine Transporte im angegebenen Zeitraum dokumentiert.", NORMAL_FONT));
} else {
// Transport table
document.add(new Paragraph("2. Transportübersicht:", SUBSECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(6);
table.setWidthPercentage(100);
table.setWidths(new float[]{1.2f, 2f, 2f, 1f, 1.5f, 1.3f});
addTableHeader(table, "Datum", "Von", "Nach", "Menge (g)", "Transporteur", "Status");
BigDecimal totalGrams = BigDecimal.ZERO;
int rowIdx = 0;
for (TransportRecord record : records) {
boolean alternate = rowIdx % 2 == 1;
String date = record.getTransportDate().format(DATE_FMT);
String from = record.getFromLocation();
String to = record.getToLocation();
String amount = formatGrams(record.getAmountGrams());
String carrier = record.getCarrierName();
String status = formatStatus(record.getStatus().name());
addDataRow(table, alternate, date, from, to, amount, carrier, status);
totalGrams = totalGrams.add(record.getAmountGrams());
rowIdx++;
}
document.add(table);
document.add(Chunk.NEWLINE);
// Summary
document.add(new Paragraph("Gesamt: " + records.size() + " Transporte, "
+ formatGrams(totalGrams) + " g", SECTION_FONT));
}
// Footer
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
document.add(new Paragraph("".repeat(80), FOOTER_FONT));
document.add(new Paragraph(
"Anbauvereinigung gemäß §2 KCanG — " + club.getName()
+ " — Generiert am " + LocalDate.now().format(DATE_FMT),
FOOTER_FONT));
document.add(new Paragraph(
"Transportdokumentation gemäß §22 Abs. 4 KCanG. Aufbewahrungspflicht: 5 Jahre (§24 KCanG).",
FOOTER_FONT));
document.close();
log.info("Generated Transport Certificate for club {} with {} records", clubId, records.size());
return baos.toByteArray();
} catch (Exception e) {
log.error("Failed to generate Transport Certificate PDF for club {}", clubId, e);
throw new RuntimeException("PDF generation failed", e);
}
}
// === Utility methods ===
private void addTableHeader(PdfPTable table, String... headers) {
for (String header : headers) {
PdfPCell cell = new PdfPCell(new Phrase(header, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(4);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
}
private void addDataRow(PdfPTable table, boolean alternate, String... values) {
for (String value : values) {
PdfPCell cell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
cell.setPadding(3);
if (alternate) cell.setBackgroundColor(LIGHT_BG);
table.addCell(cell);
}
}
private String formatGrams(BigDecimal grams) {
if (grams == null) return "0,00";
return String.format(GERMAN, "%,.2f", grams);
}
private String formatStatus(String status) {
return switch (status) {
case "PLANNED" -> "Geplant";
case "AUTHORITY_NOTIFIED" -> "Gemeldet";
case "IN_TRANSIT" -> "Unterwegs";
case "COMPLETED" -> "Abgeschlossen";
default -> status;
};
}
}
@@ -0,0 +1,9 @@
package de.cannamanage.service.report;
/**
* Parameters for year-based reports (Annual Authority Report).
*/
public record YearReportParameters(
int year
) implements ReportParameters {
}
@@ -2,8 +2,12 @@ package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.DestructionRecord;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@@ -13,4 +17,15 @@ public interface DestructionRecordRepository extends JpaRepository<DestructionRe
List<DestructionRecord> findByClubIdOrderByDestroyedAtDesc(UUID clubId);
List<DestructionRecord> findByTenantIdOrderByDestroyedAtDesc(UUID tenantId);
List<DestructionRecord> findByClubIdAndDestroyedAtBetweenOrderByDestroyedAtAsc(
UUID clubId, Instant start, Instant end);
long countByClubIdAndDestroyedAtBetween(UUID clubId, Instant start, Instant end);
@Query("SELECT COALESCE(SUM(d.amountGrams), 0) FROM DestructionRecord d " +
"WHERE d.clubId = :clubId AND d.destroyedAt >= :start AND d.destroyedAt < :end")
BigDecimal sumAmountByClubIdAndPeriod(@Param("clubId") UUID clubId,
@Param("start") Instant start,
@Param("end") Instant end);
}
@@ -4,6 +4,7 @@ import de.cannamanage.domain.entity.TransportRecord;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@@ -13,4 +14,9 @@ public interface TransportRecordRepository extends JpaRepository<TransportRecord
List<TransportRecord> findByClubIdOrderByTransportDateDesc(UUID clubId);
List<TransportRecord> findByTenantIdOrderByTransportDateDesc(UUID tenantId);
List<TransportRecord> findByClubIdAndTransportDateBetweenOrderByTransportDateAsc(
UUID clubId, LocalDate start, LocalDate end);
long countByClubIdAndTransportDateBetween(UUID clubId, LocalDate start, LocalDate end);
}