diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java index cc7a0c3..6d1ec97 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java @@ -1,10 +1,12 @@ package de.cannamanage.api.controller; +import de.cannamanage.api.dto.report.AuthorityExportRequest; import de.cannamanage.api.dto.report.MemberListResponse; import de.cannamanage.api.dto.report.MonthlyReportResponse; import de.cannamanage.api.dto.report.RecallReportResponse; import de.cannamanage.domain.entity.Club; import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.entity.User; import de.cannamanage.domain.enums.ExportFormat; import de.cannamanage.domain.enums.MemberStatus; import de.cannamanage.domain.enums.ReportType; @@ -15,12 +17,18 @@ import de.cannamanage.service.ReportService; import de.cannamanage.service.model.report.MemberListReport; import de.cannamanage.service.model.report.MonthlyReport; import de.cannamanage.service.model.report.RecallReport; +import de.cannamanage.service.report.AuthorityExportService; import de.cannamanage.service.repository.ClubRepository; +import de.cannamanage.service.repository.UserRepository; +import jakarta.validation.Valid; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import java.time.YearMonth; import java.util.*; @@ -38,17 +46,26 @@ public class ReportController { private final CsvReportGenerator csvGenerator; private final ClubRepository clubRepository; private final ReportGeneratorService reportGeneratorService; + private final AuthorityExportService authorityExportService; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; public ReportController(ReportService reportService, PdfReportGenerator pdfGenerator, CsvReportGenerator csvGenerator, ClubRepository clubRepository, - ReportGeneratorService reportGeneratorService) { + ReportGeneratorService reportGeneratorService, + AuthorityExportService authorityExportService, + UserRepository userRepository, + PasswordEncoder passwordEncoder) { this.reportService = reportService; this.pdfGenerator = pdfGenerator; this.csvGenerator = csvGenerator; this.clubRepository = clubRepository; this.reportGeneratorService = reportGeneratorService; + this.authorityExportService = authorityExportService; + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; } /** @@ -211,6 +228,49 @@ public class ReportController { ); } + /** + * Full Authority Export (Behörden-Export) — THE HERO FEATURE. + * Generates a streaming ZIP containing all compliance documents. + * Requires re-authentication (password re-entry) + mandatory reason. + * Rate limited: max 1 export per hour per tenant. + * + * POST /api/v1/reports/authority-export + */ + @PostMapping("/authority-export") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity authorityExport( + @Valid @RequestBody AuthorityExportRequest request, + @AuthenticationPrincipal UUID userId) { + + UUID tenantId = TenantContext.getCurrentTenant(); + + // Rate limit check + if (authorityExportService.isRateLimited(tenantId)) { + return ResponseEntity.status(429) + .header("Retry-After", "3600") + .build(); + } + + // Re-authentication: verify password against BCrypt hash + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalStateException("Authenticated user not found")); + if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) { + return ResponseEntity.status(403).build(); + } + + // Stream the ZIP + StreamingResponseBody responseBody = outputStream -> + authorityExportService.streamAuthorityExport( + outputStream, tenantId, request.year(), userId, request.reason()); + + String filename = "Behoerden_Export_" + request.year() + "_" + tenantId.toString().substring(0, 8) + ".zip"; + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .contentType(MediaType.parseMediaType("application/zip")) + .body(responseBody); + } + private RecallReportResponse toRecallResponse(RecallReport r) { return new RecallReportResponse( r.getBatchId(), diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/AuthorityExportRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/AuthorityExportRequest.java new file mode 100644 index 0000000..fa60221 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/report/AuthorityExportRequest.java @@ -0,0 +1,16 @@ +package de.cannamanage.api.dto.report; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * Request body for the authority export endpoint. + * Requires re-authentication (password) and a mandatory reason for the audit trail. + */ +public record AuthorityExportRequest( + @NotNull Integer year, + @NotBlank @Size(min = 1, max = 500) String password, + @NotBlank @Size(min = 10, max = 500) String reason +) { +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/AnnualAuthorityReportGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/AnnualAuthorityReportGenerator.java new file mode 100644 index 0000000..c9604eb --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/AnnualAuthorityReportGenerator.java @@ -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 { + + 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 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 allMembers = memberRepository.findByTenantId(clubId); + List distributions = distributionRepository.findByTenantIdAndDistributedAtBetween(clubId, yearStart, yearEnd); + List destructions = destructionRecordRepository.findByClubIdAndDestroyedAtBetweenOrderByDestroyedAtAsc(clubId, yearStart, yearEnd); + List transports = transportRecordRepository.findByClubIdAndTransportDateBetweenOrderByTransportDateAsc(clubId, yearStartDate, yearEndDate); + List growEntries = growEntryRepository.findAllByOrderByStartedAtDesc(); + List preventionActivities = preventionActivityRepository.findByClubIdAndActivityDateBetween(clubId, yearStartDate, yearEndDate); + + // Filter grow entries to this year's harvests + List 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 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 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 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 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 distributions, List 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 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 harvests, + List distributions, + List 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 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 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 activities, List members) throws DocumentException { + addSectionHeader(document, "8. Präventionsmaßnahmen (§23 KCanG)"); + + // Find prevention officer + Optional 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 distributions, List members) throws DocumentException { + addSectionHeader(document, "9. Jugendschutzmaßnahmen (§20 KCanG)"); + + Set 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(); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/AuthorityExportService.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/AuthorityExportService.java new file mode 100644 index 0000000..3ed1a4c --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/AuthorityExportService.java @@ -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 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 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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/BestandsfuehrungGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/BestandsfuehrungGenerator.java new file mode 100644 index 0000000..36b6477 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/BestandsfuehrungGenerator.java @@ -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 { + + 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 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 distributions = distributionRepository.findByTenantIdAndDistributedAtBetween(clubId, start, end); + List destructions = destructionRecordRepository + .findByClubIdAndDestroyedAtBetweenOrderByDestroyedAtAsc(clubId, start, end); + List 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 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 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 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 batchMovements; + + StockFlowData(BigDecimal totalHarvested, BigDecimal totalDistributed, + BigDecimal totalDestroyed, List 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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/DestructionProtocolGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/DestructionProtocolGenerator.java new file mode 100644 index 0000000..e4b24a4 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/DestructionProtocolGenerator.java @@ -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 { + + 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 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 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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/DistributionLogGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/DistributionLogGenerator.java new file mode 100644 index 0000000..ffdb34d --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/DistributionLogGenerator.java @@ -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 { + + 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 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 distributions = distributionRepository.findByTenantIdAndDistributedAtBetween(clubId, start, end); + Map memberMap = memberRepository.findByTenantId(clubId).stream() + .collect(Collectors.toMap(Member::getId, m -> m)); + Map 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 distributions = distributionRepository.findByTenantIdAndDistributedAtBetween(clubId, start, end); + Map memberMap = memberRepository.findByTenantId(clubId).stream() + .collect(Collectors.toMap(Member::getId, m -> m)); + Map 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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/PreventionActivityReportGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/PreventionActivityReportGenerator.java new file mode 100644 index 0000000..773c9ef --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/PreventionActivityReportGenerator.java @@ -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 { + + 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 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 activities = preventionActivityRepository + .findByClubIdAndActivityDateBetween(clubId, params.from(), params.to()); + + // Find prevention officer(s) + List 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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/TransportCertificateGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/TransportCertificateGenerator.java new file mode 100644 index 0000000..7bf5803 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/TransportCertificateGenerator.java @@ -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 { + + 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 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 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; + }; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/YearReportParameters.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/YearReportParameters.java new file mode 100644 index 0000000..251ec48 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/YearReportParameters.java @@ -0,0 +1,9 @@ +package de.cannamanage.service.report; + +/** + * Parameters for year-based reports (Annual Authority Report). + */ +public record YearReportParameters( + int year +) implements ReportParameters { +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/DestructionRecordRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/DestructionRecordRepository.java index 0518ced..59b0abf 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/DestructionRecordRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/DestructionRecordRepository.java @@ -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 findByClubIdOrderByDestroyedAtDesc(UUID clubId); List findByTenantIdOrderByDestroyedAtDesc(UUID tenantId); + + List 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); } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/TransportRecordRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/TransportRecordRepository.java index f1c0130..fd29041 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/TransportRecordRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/TransportRecordRepository.java @@ -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 findByClubIdOrderByTransportDateDesc(UUID clubId); List findByTenantIdOrderByTransportDateDesc(UUID tenantId); + + List findByClubIdAndTransportDateBetweenOrderByTransportDateAsc( + UUID clubId, LocalDate start, LocalDate end); + + long countByClubIdAndTransportDateBetween(UUID clubId, LocalDate start, LocalDate end); }