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,74 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.ComplianceArea;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
/**
* Tracks compliance deadlines with optional recurrence.
* Powers the compliance dashboard traffic-light system.
*/
@Entity
@Table(name = "compliance_deadlines")
public class ComplianceDeadline extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Enumerated(EnumType.STRING)
@Column(name = "area", nullable = false, length = 50)
private ComplianceArea area;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Column(name = "description")
private String description;
@Column(name = "due_date", nullable = false)
private LocalDate dueDate;
@Column(name = "is_recurring")
private Boolean isRecurring = false;
@Column(name = "recurrence_rule", length = 50)
private String recurrenceRule;
@Column(name = "completed_at")
private Instant completedAt;
@Column(name = "completed_by")
private UUID completedBy;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public ComplianceArea getArea() { return area; }
public void setArea(ComplianceArea area) { this.area = area; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public LocalDate getDueDate() { return dueDate; }
public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
public Boolean getIsRecurring() { return isRecurring; }
public void setIsRecurring(Boolean recurring) { isRecurring = recurring; }
public String getRecurrenceRule() { return recurrenceRule; }
public void setRecurrenceRule(String recurrenceRule) { this.recurrenceRule = recurrenceRule; }
public Instant getCompletedAt() { return completedAt; }
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
public UUID getCompletedBy() { return completedBy; }
public void setCompletedBy(UUID completedBy) { this.completedBy = completedBy; }
}
@@ -0,0 +1,74 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.DestructionMethod;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
/**
* Records cannabis destruction events per KCanG §22.
* Immutable compliance record — never updated after creation.
*/
@Entity
@Table(name = "destruction_records")
public class DestructionRecord extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Column(name = "batch_id")
private UUID batchId;
@Column(name = "amount_grams", nullable = false, precision = 8, scale = 2)
private BigDecimal amountGrams;
@Enumerated(EnumType.STRING)
@Column(name = "destruction_method", nullable = false, length = 50)
private DestructionMethod destructionMethod;
@Column(name = "description")
private String description;
@Column(name = "destroyed_at", nullable = false)
private Instant destroyedAt;
@Column(name = "witnessed_by")
private UUID witnessedBy;
@Column(name = "witness_name", length = 200)
private String witnessName;
@Column(name = "recorded_by", nullable = false)
private UUID recordedBy;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public UUID getBatchId() { return batchId; }
public void setBatchId(UUID batchId) { this.batchId = batchId; }
public BigDecimal getAmountGrams() { return amountGrams; }
public void setAmountGrams(BigDecimal amountGrams) { this.amountGrams = amountGrams; }
public DestructionMethod getDestructionMethod() { return destructionMethod; }
public void setDestructionMethod(DestructionMethod destructionMethod) { this.destructionMethod = destructionMethod; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Instant getDestroyedAt() { return destroyedAt; }
public void setDestroyedAt(Instant destroyedAt) { this.destroyedAt = destroyedAt; }
public UUID getWitnessedBy() { return witnessedBy; }
public void setWitnessedBy(UUID witnessedBy) { this.witnessedBy = witnessedBy; }
public String getWitnessName() { return witnessName; }
public void setWitnessName(String witnessName) { this.witnessName = witnessName; }
public UUID getRecordedBy() { return recordedBy; }
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
}
@@ -0,0 +1,82 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Metadata for generated reports. The actual file is stored on disk.
* Provides audit trail of all reports generated per tenant.
*/
@Entity
@Table(name = "generated_reports")
public class GeneratedReport extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Enumerated(EnumType.STRING)
@Column(name = "report_type", nullable = false, length = 50)
private ReportType reportType;
@Enumerated(EnumType.STRING)
@Column(name = "report_format", nullable = false, length = 10)
private ExportFormat reportFormat;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Column(name = "file_size")
private Long fileSize;
@Column(name = "storage_path", length = 500)
private String storagePath;
@Column(name = "parameters", columnDefinition = "jsonb")
private String parameters; // JSON string
@Column(name = "generated_by", nullable = false)
private UUID generatedBy;
@Column(name = "generated_at")
private Instant generatedAt;
@PrePersist
void onCreateReport() {
if (this.generatedAt == null) {
this.generatedAt = Instant.now();
}
}
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public ReportType getReportType() { return reportType; }
public void setReportType(ReportType reportType) { this.reportType = reportType; }
public ExportFormat getReportFormat() { return reportFormat; }
public void setReportFormat(ExportFormat reportFormat) { this.reportFormat = reportFormat; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public Long getFileSize() { return fileSize; }
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
public String getStoragePath() { return storagePath; }
public void setStoragePath(String storagePath) { this.storagePath = storagePath; }
public String getParameters() { return parameters; }
public void setParameters(String parameters) { this.parameters = parameters; }
public UUID getGeneratedBy() { return generatedBy; }
public void setGeneratedBy(UUID generatedBy) { this.generatedBy = generatedBy; }
public Instant getGeneratedAt() { return generatedAt; }
public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; }
}
@@ -0,0 +1,53 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.UUID;
/**
* Records prevention/education activities per KCanG §23.
* Each Anbauvereinigung must appoint a Suchtpräventionsbeauftragter.
*/
@Entity
@Table(name = "prevention_activities")
public class PreventionActivity extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Column(name = "activity_date", nullable = false)
private LocalDate activityDate;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Column(name = "description")
private String description;
@Column(name = "participants_count")
private Integer participantsCount;
@Column(name = "officer_id", nullable = false)
private UUID officerId;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public LocalDate getActivityDate() { return activityDate; }
public void setActivityDate(LocalDate activityDate) { this.activityDate = activityDate; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getParticipantsCount() { return participantsCount; }
public void setParticipantsCount(Integer participantsCount) { this.participantsCount = participantsCount; }
public UUID getOfficerId() { return officerId; }
public void setOfficerId(UUID officerId) { this.officerId = officerId; }
}
@@ -0,0 +1,65 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.UUID;
/**
* Tracks propagation material (seeds/cuttings) per KCanG §16.
* Ensures traceability of all genetic material entering the club.
*/
@Entity
@Table(name = "propagation_sources")
public class PropagationSource extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Column(name = "source_type", nullable = false, length = 50)
private String sourceType; // SEED, CUTTING
@Column(name = "supplier", length = 300)
private String supplier;
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "strain_id")
private UUID strainId;
@Column(name = "received_at", nullable = false)
private LocalDate receivedAt;
@Column(name = "documentation_reference", length = 200)
private String documentationReference;
@Column(name = "recorded_by", nullable = false)
private UUID recordedBy;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getSourceType() { return sourceType; }
public void setSourceType(String sourceType) { this.sourceType = sourceType; }
public String getSupplier() { return supplier; }
public void setSupplier(String supplier) { this.supplier = supplier; }
public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; }
public UUID getStrainId() { return strainId; }
public void setStrainId(UUID strainId) { this.strainId = strainId; }
public LocalDate getReceivedAt() { return receivedAt; }
public void setReceivedAt(LocalDate receivedAt) { this.receivedAt = receivedAt; }
public String getDocumentationReference() { return documentationReference; }
public void setDocumentationReference(String documentationReference) { this.documentationReference = documentationReference; }
public UUID getRecordedBy() { return recordedBy; }
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
}
@@ -0,0 +1,80 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.TransportStatus;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
/**
* Records cannabis transport events per KCanG §22.
* Authority notification may be required for certain transports.
*/
@Entity
@Table(name = "transport_records")
public class TransportRecord extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Column(name = "description", nullable = false)
private String description;
@Column(name = "transport_date", nullable = false)
private LocalDate transportDate;
@Column(name = "from_location", nullable = false, length = 300)
private String fromLocation;
@Column(name = "to_location", nullable = false, length = 300)
private String toLocation;
@Column(name = "carrier_name", nullable = false, length = 200)
private String carrierName;
@Column(name = "amount_grams", nullable = false, precision = 8, scale = 2)
private BigDecimal amountGrams;
@Column(name = "batch_id")
private UUID batchId;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
private TransportStatus status = TransportStatus.PLANNED;
@Column(name = "recorded_by", nullable = false)
private UUID recordedBy;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public LocalDate getTransportDate() { return transportDate; }
public void setTransportDate(LocalDate transportDate) { this.transportDate = transportDate; }
public String getFromLocation() { return fromLocation; }
public void setFromLocation(String fromLocation) { this.fromLocation = fromLocation; }
public String getToLocation() { return toLocation; }
public void setToLocation(String toLocation) { this.toLocation = toLocation; }
public String getCarrierName() { return carrierName; }
public void setCarrierName(String carrierName) { this.carrierName = carrierName; }
public BigDecimal getAmountGrams() { return amountGrams; }
public void setAmountGrams(BigDecimal amountGrams) { this.amountGrams = amountGrams; }
public UUID getBatchId() { return batchId; }
public void setBatchId(UUID batchId) { this.batchId = batchId; }
public TransportStatus getStatus() { return status; }
public void setStatus(TransportStatus status) { this.status = status; }
public UUID getRecordedBy() { return recordedBy; }
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
}
@@ -86,5 +86,12 @@ public enum AuditEventType {
// Sprint 8 — Board events
BOARD_MEMBER_ELECTED,
BOARD_MEMBER_REMOVED
BOARD_MEMBER_REMOVED,
// Sprint 9 — Reporting & Compliance events
REPORT_GENERATED,
AUTHORITY_EXPORT,
DESTRUCTION_RECORDED,
TRANSPORT_RECORDED,
RETENTION_DELETED
}
@@ -0,0 +1,12 @@
package de.cannamanage.domain.enums;
/**
* Compliance areas tracked in the compliance dashboard.
* Each area gets a status (GREEN/YELLOW/RED) based on deadline adherence.
*/
public enum ComplianceArea {
KCANG,
FINANCE,
DSGVO,
VEREIN
}
@@ -0,0 +1,10 @@
package de.cannamanage.domain.enums;
/**
* Traffic-light compliance status for the compliance dashboard.
*/
public enum ComplianceStatus {
GREEN,
YELLOW,
RED
}
@@ -0,0 +1,11 @@
package de.cannamanage.domain.enums;
/**
* Methods of cannabis destruction per KCanG §22 documentation requirements.
*/
public enum DestructionMethod {
INCINERATION,
COMPOSTING,
CHEMICAL,
OTHER
}
@@ -0,0 +1,11 @@
package de.cannamanage.domain.enums;
/**
* Supported export formats for generated reports.
*/
public enum ExportFormat {
PDF,
CSV,
JSON,
ZIP
}
@@ -25,5 +25,8 @@ public enum NotificationType {
ASSEMBLY_INVITATION,
ASSEMBLY_REMINDER,
// Sprint 8 — Board:
BOARD_TERM_EXPIRING
BOARD_TERM_EXPIRING,
// Sprint 9 — Compliance:
COMPLIANCE_DEADLINE,
RETENTION_WARNING
}
@@ -0,0 +1,34 @@
package de.cannamanage.domain.enums;
/**
* All report types available in the Berichtszentrale.
* Organized by compliance area: KCanG, Finance, DSGVO, Verein.
*/
public enum ReportType {
// KCanG compliance reports
ANNUAL_AUTHORITY,
DISTRIBUTION_LOG,
STOCK_INVENTORY,
DESTRUCTION_PROTOCOL,
CULTIVATION_REPORT,
TRANSPORT_CERTIFICATE,
FULL_AUTHORITY_EXPORT,
// Financial reports
EUR,
ANNUAL_FINANCIAL,
KASSENBUCH_EXPORT,
FEE_CONFIRMATION,
// Verein administration
MEMBER_LIST_REGISTRY,
BOARD_CHANGE_NOTICE,
ANNUAL_BOARD_REPORT,
// DSGVO reports
VVT,
TOM,
DSFA,
DELETION_CONCEPT,
BREACH_NOTIFICATION
}
@@ -0,0 +1,14 @@
package de.cannamanage.domain.enums;
/**
* Data retention categories per German law.
* - KCanG §24: 5 years for cannabis-specific records
* - AO §147: 6/8/10 years for financial/tax records
*/
public enum RetentionCategory {
KCANG_5Y,
AO_6Y,
AO_8Y,
AO_10Y,
INDEFINITE
}
@@ -22,5 +22,9 @@ public enum StaffPermission {
MANAGE_FINANCES,
VIEW_FINANCES,
MANAGE_ASSEMBLIES,
MANAGE_DOCUMENTS
MANAGE_DOCUMENTS,
// Sprint 9:
GENERATE_REPORTS,
VIEW_COMPLIANCE,
MANAGE_COMPLIANCE
}
@@ -0,0 +1,11 @@
package de.cannamanage.domain.enums;
/**
* Status of a transport record per KCanG §22 transport documentation.
*/
public enum TransportStatus {
PLANNED,
AUTHORITY_NOTIFIED,
IN_TRANSIT,
COMPLETED
}