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,97 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.ComplianceDeadline;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.ComplianceArea;
import de.cannamanage.service.repository.ComplianceDeadlineRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* REST controller for compliance deadline management.
* Powers the compliance dashboard traffic-light system.
*/
@RestController
@RequestMapping("/api/v1/compliance/deadlines")
@RequiredArgsConstructor
@Tag(name = "Compliance Deadlines", description = "Manage compliance deadlines and due dates")
public class ComplianceDeadlineController {
private final ComplianceDeadlineRepository deadlineRepository;
@GetMapping
@Operation(summary = "List all deadlines (upcoming + overdue)")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<ComplianceDeadline>> listDeadlines() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(deadlineRepository.findByTenantIdOrderByDueDateAsc(tenantId));
}
@PostMapping
@Operation(summary = "Create a new compliance deadline")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<ComplianceDeadline> createDeadline(@RequestBody CreateDeadlineRequest request) {
ComplianceDeadline deadline = new ComplianceDeadline();
deadline.setClubId(request.clubId());
deadline.setArea(request.area());
deadline.setTitle(request.title());
deadline.setDescription(request.description());
deadline.setDueDate(request.dueDate());
deadline.setIsRecurring(request.isRecurring() != null ? request.isRecurring() : false);
deadline.setRecurrenceRule(request.recurrenceRule());
return ResponseEntity.ok(deadlineRepository.save(deadline));
}
@PostMapping("/{id}/complete")
@Operation(summary = "Mark a deadline as complete")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<ComplianceDeadline> completeDeadline(
@PathVariable UUID id,
@RequestBody CompleteDeadlineRequest request) {
ComplianceDeadline deadline = deadlineRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Deadline not found: " + id));
deadline.setCompletedAt(Instant.now());
deadline.setCompletedBy(request.completedBy());
return ResponseEntity.ok(deadlineRepository.save(deadline));
}
@GetMapping("/overdue")
@Operation(summary = "List overdue (incomplete, past due date) deadlines")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<ComplianceDeadline>> listOverdue() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(
deadlineRepository.findByTenantIdAndCompletedAtIsNullOrderByDueDateAsc(tenantId)
.stream()
.filter(d -> d.getDueDate().isBefore(LocalDate.now()))
.toList()
);
}
public record CreateDeadlineRequest(
UUID clubId,
ComplianceArea area,
String title,
String description,
LocalDate dueDate,
Boolean isRecurring,
String recurrenceRule
) {}
public record CompleteDeadlineRequest(
UUID completedBy
) {}
}
@@ -0,0 +1,190 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.DestructionMethod;
import de.cannamanage.domain.enums.TransportStatus;
import de.cannamanage.service.repository.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* REST controller for KCanG §22 compliance records:
* destruction, transport, propagation sources, and prevention activities.
*/
@RestController
@RequestMapping("/api/v1/compliance")
@RequiredArgsConstructor
@Tag(name = "Compliance Records", description = "KCanG §22 record keeping for destruction, transport, propagation & prevention")
public class ComplianceRecordsController {
private final DestructionRecordRepository destructionRecordRepository;
private final TransportRecordRepository transportRecordRepository;
private final PropagationSourceRepository propagationSourceRepository;
private final PreventionActivityRepository preventionActivityRepository;
// === Destruction Records ===
@PostMapping("/destruction-records")
@Operation(summary = "Record a cannabis destruction event")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<DestructionRecord> recordDestruction(@RequestBody CreateDestructionRequest request) {
DestructionRecord record = new DestructionRecord();
record.setClubId(request.clubId());
record.setBatchId(request.batchId());
record.setAmountGrams(request.amountGrams());
record.setDestructionMethod(request.destructionMethod());
record.setDescription(request.description());
record.setDestroyedAt(request.destroyedAt() != null ? request.destroyedAt() : Instant.now());
record.setWitnessedBy(request.witnessedBy());
record.setWitnessName(request.witnessName());
record.setRecordedBy(request.recordedBy());
return ResponseEntity.ok(destructionRecordRepository.save(record));
}
@GetMapping("/destruction-records")
@Operation(summary = "List destruction records for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<DestructionRecord>> listDestructionRecords() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(destructionRecordRepository.findByTenantIdOrderByDestroyedAtDesc(tenantId));
}
// === Transport Records ===
@PostMapping("/transport-records")
@Operation(summary = "Record a cannabis transport event")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<TransportRecord> recordTransport(@RequestBody CreateTransportRequest request) {
TransportRecord record = new TransportRecord();
record.setClubId(request.clubId());
record.setDescription(request.description());
record.setTransportDate(request.transportDate());
record.setFromLocation(request.fromLocation());
record.setToLocation(request.toLocation());
record.setCarrierName(request.carrierName());
record.setAmountGrams(request.amountGrams());
record.setBatchId(request.batchId());
record.setStatus(request.status() != null ? request.status() : TransportStatus.PLANNED);
record.setRecordedBy(request.recordedBy());
return ResponseEntity.ok(transportRecordRepository.save(record));
}
@GetMapping("/transport-records")
@Operation(summary = "List transport records for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<TransportRecord>> listTransportRecords() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(transportRecordRepository.findByTenantIdOrderByTransportDateDesc(tenantId));
}
// === Propagation Sources ===
@PostMapping("/propagation-sources")
@Operation(summary = "Record a propagation source (seed/cutting receipt)")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<PropagationSource> recordPropagationSource(@RequestBody CreatePropagationSourceRequest request) {
PropagationSource record = new PropagationSource();
record.setClubId(request.clubId());
record.setSourceType(request.sourceType());
record.setSupplier(request.supplier());
record.setQuantity(request.quantity());
record.setStrainId(request.strainId());
record.setReceivedAt(request.receivedAt());
record.setDocumentationReference(request.documentationReference());
record.setRecordedBy(request.recordedBy());
return ResponseEntity.ok(propagationSourceRepository.save(record));
}
@GetMapping("/propagation-sources")
@Operation(summary = "List propagation sources for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<PropagationSource>> listPropagationSources() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(propagationSourceRepository.findByTenantIdOrderByReceivedAtDesc(tenantId));
}
// === Prevention Activities ===
@PostMapping("/prevention-activities")
@Operation(summary = "Record a prevention/education activity per KCanG §23")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<PreventionActivity> recordPreventionActivity(@RequestBody CreatePreventionActivityRequest request) {
PreventionActivity record = new PreventionActivity();
record.setClubId(request.clubId());
record.setActivityDate(request.activityDate());
record.setTitle(request.title());
record.setDescription(request.description());
record.setParticipantsCount(request.participantsCount());
record.setOfficerId(request.officerId());
return ResponseEntity.ok(preventionActivityRepository.save(record));
}
@GetMapping("/prevention-activities")
@Operation(summary = "List prevention activities for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<PreventionActivity>> listPreventionActivities() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(preventionActivityRepository.findByTenantIdOrderByActivityDateDesc(tenantId));
}
// === Request DTOs (inner records) ===
public record CreateDestructionRequest(
UUID clubId,
UUID batchId,
BigDecimal amountGrams,
DestructionMethod destructionMethod,
String description,
Instant destroyedAt,
UUID witnessedBy,
String witnessName,
UUID recordedBy
) {}
public record CreateTransportRequest(
UUID clubId,
String description,
LocalDate transportDate,
String fromLocation,
String toLocation,
String carrierName,
BigDecimal amountGrams,
UUID batchId,
TransportStatus status,
UUID recordedBy
) {}
public record CreatePropagationSourceRequest(
UUID clubId,
String sourceType,
String supplier,
Integer quantity,
UUID strainId,
LocalDate receivedAt,
String documentationReference,
UUID recordedBy
) {}
public record CreatePreventionActivityRequest(
UUID clubId,
LocalDate activityDate,
String title,
String description,
Integer participantsCount,
UUID officerId
) {}
}
@@ -0,0 +1,18 @@
-- Sprint 9: Destruction records per KCanG §22
CREATE TABLE destruction_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
batch_id UUID REFERENCES batches(id),
amount_grams NUMERIC(8,2) NOT NULL,
destruction_method VARCHAR(50) NOT NULL,
description TEXT,
destroyed_at TIMESTAMP NOT NULL,
witnessed_by UUID REFERENCES users(id),
witness_name VARCHAR(200),
recorded_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_destruction_records_tenant ON destruction_records(tenant_id);
CREATE INDEX idx_destruction_records_club ON destruction_records(club_id);
@@ -0,0 +1,19 @@
-- Sprint 9: Transport records per KCanG §22 transport documentation
CREATE TABLE transport_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
description TEXT NOT NULL,
transport_date DATE NOT NULL,
from_location VARCHAR(300) NOT NULL,
to_location VARCHAR(300) NOT NULL,
carrier_name VARCHAR(200) NOT NULL,
amount_grams NUMERIC(8,2) NOT NULL,
batch_id UUID REFERENCES batches(id),
status VARCHAR(50) NOT NULL DEFAULT 'PLANNED',
recorded_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_transport_records_tenant ON transport_records(tenant_id);
CREATE INDEX idx_transport_records_club ON transport_records(club_id);
@@ -0,0 +1,17 @@
-- Sprint 9: Propagation sources (seed/cutting tracking per KCanG §16)
CREATE TABLE propagation_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
source_type VARCHAR(50) NOT NULL, -- SEED, CUTTING
supplier VARCHAR(300),
quantity INTEGER NOT NULL,
strain_id UUID REFERENCES strains(id),
received_at DATE NOT NULL,
documentation_reference VARCHAR(200),
recorded_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_propagation_sources_tenant ON propagation_sources(tenant_id);
CREATE INDEX idx_propagation_sources_club ON propagation_sources(club_id);
@@ -0,0 +1,15 @@
-- Sprint 9: Prevention activities per KCanG §23 Suchtprävention
CREATE TABLE prevention_activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
activity_date DATE NOT NULL,
title VARCHAR(300) NOT NULL,
description TEXT,
participants_count INTEGER,
officer_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_prevention_activities_tenant ON prevention_activities(tenant_id);
CREATE INDEX idx_prevention_activities_club ON prevention_activities(club_id);
@@ -0,0 +1,18 @@
-- Sprint 9: Generated reports metadata
CREATE TABLE generated_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
report_type VARCHAR(50) NOT NULL,
report_format VARCHAR(10) NOT NULL,
title VARCHAR(300) NOT NULL,
file_size BIGINT,
storage_path VARCHAR(500),
parameters JSONB,
generated_by UUID NOT NULL REFERENCES users(id),
generated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_generated_reports_tenant ON generated_reports(tenant_id);
CREATE INDEX idx_generated_reports_club ON generated_reports(club_id);
CREATE INDEX idx_generated_reports_type ON generated_reports(club_id, report_type);
@@ -0,0 +1,18 @@
-- Sprint 9: Compliance deadlines tracking
CREATE TABLE compliance_deadlines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
area VARCHAR(50) NOT NULL,
title VARCHAR(300) NOT NULL,
description TEXT,
due_date DATE NOT NULL,
is_recurring BOOLEAN DEFAULT FALSE,
recurrence_rule VARCHAR(50),
completed_at TIMESTAMP,
completed_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_compliance_deadlines_tenant ON compliance_deadlines(tenant_id);
CREATE INDEX idx_compliance_deadlines_club_due ON compliance_deadlines(club_id, due_date);
@@ -0,0 +1,4 @@
-- Sprint 9: Add THC/CBD percentage + strain name to distributions (KCanG §19(4))
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS thc_percentage NUMERIC(4,2);
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS cbd_percentage NUMERIC(4,2);
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS strain_name VARCHAR(200);