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:
+97
@@ -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
|
||||
) {}
|
||||
}
|
||||
+190
@@ -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);
|
||||
Reference in New Issue
Block a user