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:
+666
@@ -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("18–21", members.stream().filter(m -> getAge(m, year) >= 18 && getAge(m, year) <= 21).count());
|
||||
ageGroups.put("22–30", members.stream().filter(m -> getAge(m, year) >= 22 && getAge(m, year) <= 30).count());
|
||||
ageGroups.put("31–40", members.stream().filter(m -> getAge(m, year) >= 31 && getAge(m, year) <= 40).count());
|
||||
ageGroups.put("41–50", members.stream().filter(m -> getAge(m, year) >= 41 && getAge(m, year) <= 50).count());
|
||||
ageGroups.put("51–65", 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();
|
||||
}
|
||||
}
|
||||
+269
@@ -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;
|
||||
}
|
||||
}
|
||||
+395
@@ -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);
|
||||
}
|
||||
}
|
||||
+260
@@ -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;
|
||||
}
|
||||
}
|
||||
+278
@@ -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);
|
||||
}
|
||||
}
|
||||
+224
@@ -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;
|
||||
}
|
||||
}
|
||||
+223
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package de.cannamanage.service.report;
|
||||
|
||||
/**
|
||||
* Parameters for year-based reports (Annual Authority Report).
|
||||
*/
|
||||
public record YearReportParameters(
|
||||
int year
|
||||
) implements ReportParameters {
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user