feat(sprint-6): Phase 5 — Full grow calendar (sensors, photos, feeding, harvest traceability)
- V9 migration: grow_entries, grow_stage_logs, sensor_readings, grow_photos, feeding_logs - 5 entities + GrowStage enum (7 stages) + SensorReadingType enum - GrowCalendarService: CRUD + stage advancement + harvest-to-batch linking - GrowCalendarController: 8 endpoints (/api/v1/grow/*) - Frontend: /grow list + /grow/[id] detail (timeline, sensor charts, photo gallery, feeding log) - Sensor chart (Recharts line: temp + humidity over time) - Harvest completion links grow entry → batch (full traceability) - React Query hooks for all grow operations - Full i18n (de/en) with 7 grow stage labels - Sidebar navigation updated with Anbau/Grow entry
This commit is contained in:
+135
@@ -0,0 +1,135 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.grow.*;
|
||||
import de.cannamanage.domain.entity.*;
|
||||
import de.cannamanage.domain.enums.GrowStage;
|
||||
import de.cannamanage.domain.enums.SensorReadingType;
|
||||
import de.cannamanage.service.GrowCalendarService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/grow")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Grow Calendar", description = "Grow lifecycle management with sensors, photos, and feeding")
|
||||
public class GrowCalendarController {
|
||||
|
||||
private final GrowCalendarService growCalendarService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all grow entries")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||
public ResponseEntity<List<GrowEntryResponse>> listGrowEntries() {
|
||||
List<GrowEntryResponse> entries = growCalendarService.getGrowEntries().stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(entries);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a new grow entry")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||
public ResponseEntity<GrowEntryResponse> createGrowEntry(@Valid @RequestBody CreateGrowEntryRequest request) {
|
||||
GrowEntry entry = growCalendarService.createGrowEntry(
|
||||
request.name(), request.strainId(), request.notes(), request.expectedHarvestAt());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entry));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get grow entry detail with stages, sensors, photos, feedings")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||
public ResponseEntity<GrowEntryDetailResponse> getGrowEntry(@PathVariable UUID id) {
|
||||
GrowEntry entry = growCalendarService.getGrowEntry(id);
|
||||
List<GrowStageLog> stages = growCalendarService.getStageLogs(id);
|
||||
List<SensorReading> sensors = growCalendarService.getSensorReadings(id);
|
||||
List<GrowPhoto> photos = growCalendarService.getPhotos(id);
|
||||
List<FeedingLog> feedings = growCalendarService.getFeedingLogs(id);
|
||||
|
||||
GrowEntryDetailResponse detail = new GrowEntryDetailResponse(
|
||||
entry.getId(), entry.getName(), entry.getStrainId(), entry.getStatus(),
|
||||
entry.getStartedAt(), entry.getExpectedHarvestAt(), entry.getActualHarvestAt(),
|
||||
entry.getHarvestedGrams(), entry.getLinkedBatchId(), entry.getNotes(),
|
||||
stages.stream().map(this::toStageResponse).toList(),
|
||||
sensors.stream().map(this::toSensorResponse).toList(),
|
||||
photos.stream().map(this::toPhotoResponse).toList(),
|
||||
feedings.stream().map(this::toFeedingResponse).toList()
|
||||
);
|
||||
return ResponseEntity.ok(detail);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/stage")
|
||||
@Operation(summary = "Advance to next stage")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||
public ResponseEntity<GrowEntryResponse> advanceStage(@PathVariable UUID id, @Valid @RequestBody AdvanceStageRequest request) {
|
||||
GrowEntry entry = growCalendarService.advanceStage(id, request.stage());
|
||||
return ResponseEntity.ok(toResponse(entry));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/sensors")
|
||||
@Operation(summary = "Add sensor reading")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||
public ResponseEntity<SensorReadingResponse> addSensorReading(@PathVariable UUID id, @Valid @RequestBody AddSensorReadingRequest request) {
|
||||
SensorReading reading = growCalendarService.addSensorReading(id, request.readingType(), request.value(), request.unit());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toSensorResponse(reading));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/photos")
|
||||
@Operation(summary = "Add photo")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||
public ResponseEntity<GrowPhotoResponse> addPhoto(@PathVariable UUID id, @Valid @RequestBody AddPhotoRequest request) {
|
||||
GrowPhoto photo = growCalendarService.addPhoto(id, request.filePath(), request.caption());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toPhotoResponse(photo));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/feedings")
|
||||
@Operation(summary = "Add feeding log")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||
public ResponseEntity<FeedingLogResponse> addFeedingLog(@PathVariable UUID id, @Valid @RequestBody AddFeedingLogRequest request) {
|
||||
FeedingLog feeding = growCalendarService.addFeedingLog(id,
|
||||
request.nutrientName(), request.amountMl(), request.waterLiters(),
|
||||
request.phAfter(), request.ecAfter(), request.notes());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toFeedingResponse(feeding));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/harvest")
|
||||
@Operation(summary = "Complete harvest and link to batch")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||
public ResponseEntity<GrowEntryResponse> completeHarvest(@PathVariable UUID id, @Valid @RequestBody CompleteHarvestRequest request) {
|
||||
GrowEntry entry = growCalendarService.completeHarvest(id, request.harvestedGrams(), request.linkedBatchId());
|
||||
return ResponseEntity.ok(toResponse(entry));
|
||||
}
|
||||
|
||||
// --- Mapping helpers ---
|
||||
|
||||
private GrowEntryResponse toResponse(GrowEntry e) {
|
||||
return new GrowEntryResponse(e.getId(), e.getName(), e.getStrainId(), e.getStatus(),
|
||||
e.getStartedAt(), e.getExpectedHarvestAt(), e.getActualHarvestAt(),
|
||||
e.getHarvestedGrams(), e.getLinkedBatchId(), e.getNotes());
|
||||
}
|
||||
|
||||
private GrowStageLogResponse toStageResponse(GrowStageLog s) {
|
||||
return new GrowStageLogResponse(s.getId(), s.getStage(), s.getStartedAt(), s.getEndedAt(), s.getNotes());
|
||||
}
|
||||
|
||||
private SensorReadingResponse toSensorResponse(SensorReading r) {
|
||||
return new SensorReadingResponse(r.getId(), r.getReadingType(), r.getValue(), r.getUnit(), r.getRecordedAt());
|
||||
}
|
||||
|
||||
private GrowPhotoResponse toPhotoResponse(GrowPhoto p) {
|
||||
return new GrowPhotoResponse(p.getId(), p.getFilePath(), p.getCaption(), p.getTakenAt());
|
||||
}
|
||||
|
||||
private FeedingLogResponse toFeedingResponse(FeedingLog f) {
|
||||
return new FeedingLogResponse(f.getId(), f.getNutrientName(), f.getAmountMl(),
|
||||
f.getWaterLiters(), f.getPhAfter(), f.getEcAfter(), f.getFedAt(), f.getNotes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record AddFeedingLogRequest(
|
||||
@NotBlank String nutrientName,
|
||||
@NotNull BigDecimal amountMl,
|
||||
BigDecimal waterLiters,
|
||||
BigDecimal phAfter,
|
||||
BigDecimal ecAfter,
|
||||
String notes
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record AddPhotoRequest(
|
||||
@NotBlank String filePath,
|
||||
String caption
|
||||
) {}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import de.cannamanage.domain.enums.SensorReadingType;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record AddSensorReadingRequest(
|
||||
@NotNull SensorReadingType readingType,
|
||||
@NotNull BigDecimal value,
|
||||
@NotBlank String unit
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import de.cannamanage.domain.enums.GrowStage;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record AdvanceStageRequest(
|
||||
@NotNull GrowStage stage
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CompleteHarvestRequest(
|
||||
@NotNull BigDecimal harvestedGrams,
|
||||
UUID linkedBatchId
|
||||
) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CreateGrowEntryRequest(
|
||||
@NotBlank String name,
|
||||
UUID strainId,
|
||||
String notes,
|
||||
Instant expectedHarvestAt
|
||||
) {}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record FeedingLogResponse(
|
||||
UUID id,
|
||||
String nutrientName,
|
||||
BigDecimal amountMl,
|
||||
BigDecimal waterLiters,
|
||||
BigDecimal phAfter,
|
||||
BigDecimal ecAfter,
|
||||
Instant fedAt,
|
||||
String notes
|
||||
) {}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import de.cannamanage.domain.enums.GrowStage;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record GrowEntryDetailResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
UUID strainId,
|
||||
GrowStage status,
|
||||
Instant startedAt,
|
||||
Instant expectedHarvestAt,
|
||||
Instant actualHarvestAt,
|
||||
BigDecimal harvestedGrams,
|
||||
UUID linkedBatchId,
|
||||
String notes,
|
||||
List<GrowStageLogResponse> stages,
|
||||
List<SensorReadingResponse> sensors,
|
||||
List<GrowPhotoResponse> photos,
|
||||
List<FeedingLogResponse> feedings
|
||||
) {}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import de.cannamanage.domain.enums.GrowStage;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record GrowEntryResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
UUID strainId,
|
||||
GrowStage status,
|
||||
Instant startedAt,
|
||||
Instant expectedHarvestAt,
|
||||
Instant actualHarvestAt,
|
||||
BigDecimal harvestedGrams,
|
||||
UUID linkedBatchId,
|
||||
String notes
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record GrowPhotoResponse(
|
||||
UUID id,
|
||||
String filePath,
|
||||
String caption,
|
||||
Instant takenAt
|
||||
) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import de.cannamanage.domain.enums.GrowStage;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record GrowStageLogResponse(
|
||||
UUID id,
|
||||
GrowStage stage,
|
||||
Instant startedAt,
|
||||
Instant endedAt,
|
||||
String notes
|
||||
) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.api.dto.grow;
|
||||
|
||||
import de.cannamanage.domain.enums.SensorReadingType;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record SensorReadingResponse(
|
||||
UUID id,
|
||||
SensorReadingType readingType,
|
||||
BigDecimal value,
|
||||
String unit,
|
||||
Instant recordedAt
|
||||
) {}
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Grow entries (one per plant batch lifecycle)
|
||||
CREATE TABLE IF NOT EXISTS grow_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
strain_id UUID REFERENCES strains(id),
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'SEEDLING',
|
||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
expected_harvest_at TIMESTAMP WITH TIME ZONE,
|
||||
actual_harvest_at TIMESTAMP WITH TIME ZONE,
|
||||
harvested_grams NUMERIC(8,1),
|
||||
linked_batch_id UUID REFERENCES batches(id),
|
||||
notes TEXT,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Grow stage transitions
|
||||
CREATE TABLE IF NOT EXISTS grow_stage_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||
stage VARCHAR(30) NOT NULL,
|
||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMP WITH TIME ZONE,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
-- Sensor readings
|
||||
CREATE TABLE IF NOT EXISTS sensor_readings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||
reading_type VARCHAR(30) NOT NULL,
|
||||
value NUMERIC(8,2) NOT NULL,
|
||||
unit VARCHAR(10) NOT NULL,
|
||||
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Photos
|
||||
CREATE TABLE IF NOT EXISTS grow_photos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
caption VARCHAR(255),
|
||||
taken_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Feeding schedule entries
|
||||
CREATE TABLE IF NOT EXISTS feeding_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||
nutrient_name VARCHAR(100) NOT NULL,
|
||||
amount_ml NUMERIC(8,1) NOT NULL,
|
||||
water_liters NUMERIC(8,1),
|
||||
ph_after NUMERIC(4,2),
|
||||
ec_after NUMERIC(6,2),
|
||||
fed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_grow_entries_tenant ON grow_entries(tenant_id);
|
||||
CREATE INDEX idx_grow_entries_status ON grow_entries(status);
|
||||
CREATE INDEX idx_sensor_readings_entry ON sensor_readings(grow_entry_id, recorded_at DESC);
|
||||
CREATE INDEX idx_feeding_logs_entry ON feeding_logs(grow_entry_id, fed_at DESC);
|
||||
CREATE INDEX idx_grow_photos_entry ON grow_photos(grow_entry_id);
|
||||
CREATE INDEX idx_grow_stage_logs_entry ON grow_stage_logs(grow_entry_id);
|
||||
Reference in New Issue
Block a user