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