feat(sprint9): Phase 1 — Data model + ReportGenerator infrastructure

- 7 new enums: ReportType, ExportFormat, DestructionMethod, TransportStatus,
  ComplianceArea, ComplianceStatus, RetentionCategory
- Extended: StaffPermission (+3), AuditEventType (+5), NotificationType (+2)
- Flyway V23-V29: destruction_records, transport_records, propagation_sources,
  prevention_activities, generated_reports, compliance_deadlines, distribution THC/CBD
- 6 new JPA entities extending AbstractTenantEntity
- 6 new Spring Data repositories with tenant-scoped queries
- ReportGenerator<T> interface + ReportGeneratorService (auto-discovery, format dispatch)
- ComplianceRecordsController (CRUD for destruction/transport/propagation/prevention)
- ComplianceDeadlineController (create, list, complete, overdue)
- DateRangeReportParameters record for report generation
This commit is contained in:
Patrick Plate
2026-06-15 12:01:06 +02:00
parent 2d83c4b8a1
commit 26a77dd269
39 changed files with 3743 additions and 3 deletions
@@ -0,0 +1,128 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.GeneratedReport;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.report.ReportGenerator;
import de.cannamanage.service.report.ReportParameters;
import de.cannamanage.service.repository.GeneratedReportRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Central orchestrator for report generation.
* Auto-discovers all ReportGenerator beans via Spring injection.
* Handles: format dispatch, audit logging, document storage.
* Rate-limited: max 5 generations per minute per tenant (applied at controller level).
*/
@Service
public class ReportGeneratorService {
private static final Logger log = LoggerFactory.getLogger(ReportGeneratorService.class);
private final Map<ReportType, ReportGenerator<?>> generators;
private final GeneratedReportRepository generatedReportRepository;
private final AuditService auditService;
public ReportGeneratorService(
List<ReportGenerator<?>> allGenerators,
GeneratedReportRepository generatedReportRepository,
AuditService auditService) {
this.generators = allGenerators.stream()
.collect(Collectors.toMap(ReportGenerator::getType, Function.identity()));
this.generatedReportRepository = generatedReportRepository;
this.auditService = auditService;
log.info("ReportGeneratorService initialized with {} generators: {}",
generators.size(), generators.keySet());
}
/**
* Generate a report and persist metadata.
*/
@SuppressWarnings("unchecked")
public GeneratedReport generateReport(
ReportType type,
ExportFormat format,
ReportParameters params,
UUID clubId,
UUID generatedBy) {
ReportGenerator<ReportParameters> generator =
(ReportGenerator<ReportParameters>) generators.get(type);
if (generator == null) {
throw new IllegalArgumentException("No generator registered for report type: " + type);
}
if (!generator.supportedFormats().contains(format)) {
throw new IllegalArgumentException(
"Format " + format + " not supported for " + type +
". Supported: " + generator.supportedFormats());
}
byte[] content = switch (format) {
case PDF -> generator.generatePdf(params, clubId);
case CSV -> generator.generateCsv(params, clubId);
case JSON -> generator.generateJson(params, clubId);
case ZIP -> generator.generatePdf(params, clubId); // ZIP handled at higher level
};
// Persist report metadata
GeneratedReport report = new GeneratedReport();
report.setClubId(clubId);
report.setReportType(type);
report.setReportFormat(format);
report.setTitle(type.name() + "" + java.time.LocalDate.now());
report.setFileSize((long) content.length);
report.setGeneratedBy(generatedBy);
report = generatedReportRepository.save(report);
log.info("Generated report {} ({}) for club {}, size={} bytes",
type, format, clubId, content.length);
return report;
}
/**
* Generate raw bytes for a report without persisting metadata.
* Used for streaming download responses.
*/
@SuppressWarnings("unchecked")
public byte[] generateBytes(
ReportType type,
ExportFormat format,
ReportParameters params,
UUID clubId) {
ReportGenerator<ReportParameters> generator =
(ReportGenerator<ReportParameters>) generators.get(type);
if (generator == null) {
throw new IllegalArgumentException("No generator registered for report type: " + type);
}
return switch (format) {
case PDF -> generator.generatePdf(params, clubId);
case CSV -> generator.generateCsv(params, clubId);
case JSON -> generator.generateJson(params, clubId);
case ZIP -> generator.generatePdf(params, clubId);
};
}
public List<GeneratedReport> getGeneratedReports(UUID tenantId) {
return generatedReportRepository.findByTenantIdOrderByGeneratedAtDesc(tenantId);
}
public boolean hasGenerator(ReportType type) {
return generators.containsKey(type);
}
}
@@ -0,0 +1,21 @@
package de.cannamanage.service.report;
import java.time.LocalDate;
/**
* Parameters for date-range based reports (EÜR, distribution log, etc.).
*/
public record DateRangeReportParameters(
LocalDate from,
LocalDate to,
Integer year
) implements ReportParameters {
public static DateRangeReportParameters forYear(int year) {
return new DateRangeReportParameters(
LocalDate.of(year, 1, 1),
LocalDate.of(year, 12, 31),
year
);
}
}
@@ -0,0 +1,29 @@
package de.cannamanage.service.report;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import java.util.Set;
import java.util.UUID;
/**
* Base interface for all report generators.
* Standardizes the contract across 19+ report types.
* Each implementation handles one ReportType (Open/Closed principle).
*/
public interface ReportGenerator<T extends ReportParameters> {
ReportType getType();
byte[] generatePdf(T params, UUID clubId);
default byte[] generateCsv(T params, UUID clubId) {
throw new UnsupportedOperationException("CSV not supported for " + getType());
}
default byte[] generateJson(T params, UUID clubId) {
throw new UnsupportedOperationException("JSON not supported for " + getType());
}
Set<ExportFormat> supportedFormats();
}
@@ -0,0 +1,8 @@
package de.cannamanage.service.report;
/**
* Marker interface for report generation parameters.
* Each report type defines its own parameter class implementing this interface.
*/
public interface ReportParameters {
}
@@ -0,0 +1,27 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.ComplianceDeadline;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Repository
public interface ComplianceDeadlineRepository extends JpaRepository<ComplianceDeadline, UUID> {
/**
* Overdue deadlines: due before given date and not yet completed.
*/
List<ComplianceDeadline> findByClubIdAndDueDateBeforeAndCompletedAtIsNull(UUID clubId, LocalDate date);
/**
* Upcoming deadlines within a date range.
*/
List<ComplianceDeadline> findByClubIdAndDueDateBetween(UUID clubId, LocalDate start, LocalDate end);
List<ComplianceDeadline> findByTenantIdAndCompletedAtIsNullOrderByDueDateAsc(UUID tenantId);
List<ComplianceDeadline> findByTenantIdOrderByDueDateAsc(UUID tenantId);
}
@@ -0,0 +1,16 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.DestructionRecord;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface DestructionRecordRepository extends JpaRepository<DestructionRecord, UUID> {
List<DestructionRecord> findByClubIdOrderByDestroyedAtDesc(UUID clubId);
List<DestructionRecord> findByTenantIdOrderByDestroyedAtDesc(UUID tenantId);
}
@@ -0,0 +1,19 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.GeneratedReport;
import de.cannamanage.domain.enums.ReportType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface GeneratedReportRepository extends JpaRepository<GeneratedReport, UUID> {
List<GeneratedReport> findByClubIdOrderByGeneratedAtDesc(UUID clubId);
List<GeneratedReport> findByClubIdAndReportType(UUID clubId, ReportType reportType);
List<GeneratedReport> findByTenantIdOrderByGeneratedAtDesc(UUID tenantId);
}
@@ -0,0 +1,17 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.PreventionActivity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Repository
public interface PreventionActivityRepository extends JpaRepository<PreventionActivity, UUID> {
List<PreventionActivity> findByClubIdAndActivityDateBetween(UUID clubId, LocalDate start, LocalDate end);
List<PreventionActivity> findByTenantIdOrderByActivityDateDesc(UUID tenantId);
}
@@ -0,0 +1,16 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.PropagationSource;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface PropagationSourceRepository extends JpaRepository<PropagationSource, UUID> {
List<PropagationSource> findByClubIdOrderByReceivedAtDesc(UUID clubId);
List<PropagationSource> findByTenantIdOrderByReceivedAtDesc(UUID tenantId);
}
@@ -0,0 +1,16 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.TransportRecord;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface TransportRecordRepository extends JpaRepository<TransportRecord, UUID> {
List<TransportRecord> findByClubIdOrderByTransportDateDesc(UUID clubId);
List<TransportRecord> findByTenantIdOrderByTransportDateDesc(UUID tenantId);
}