feat(sprint-6): Phase 5 — Full grow calendar (sensors, photos, feeding, harvest traceability)
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

- 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:
Patrick Plate
2026-06-12 22:51:45 +02:00
parent 05933a08ca
commit 076fd6f9b3
34 changed files with 1843 additions and 2 deletions
@@ -0,0 +1,189 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.GrowStage;
import de.cannamanage.domain.enums.SensorReadingType;
import de.cannamanage.service.repository.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class GrowCalendarService {
private final GrowEntryRepository growEntryRepository;
private final GrowStageLogRepository growStageLogRepository;
private final SensorReadingRepository sensorReadingRepository;
private final GrowPhotoRepository growPhotoRepository;
private final FeedingLogRepository feedingLogRepository;
private final BatchRepository batchRepository;
@Transactional(readOnly = true)
public List<GrowEntry> getGrowEntries() {
return growEntryRepository.findAllByOrderByStartedAtDesc();
}
@Transactional(readOnly = true)
public GrowEntry getGrowEntry(UUID id) {
return growEntryRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Grow entry not found"));
}
@Transactional
public GrowEntry createGrowEntry(String name, UUID strainId, String notes, Instant expectedHarvestAt) {
GrowEntry entry = new GrowEntry();
entry.setName(name);
entry.setStrainId(strainId);
entry.setStatus(GrowStage.SEEDLING);
entry.setStartedAt(Instant.now());
entry.setExpectedHarvestAt(expectedHarvestAt);
entry.setNotes(notes);
GrowEntry saved = growEntryRepository.save(entry);
// Create initial stage log
GrowStageLog stageLog = new GrowStageLog();
stageLog.setGrowEntryId(saved.getId());
stageLog.setStage(GrowStage.SEEDLING);
stageLog.setStartedAt(Instant.now());
growStageLogRepository.save(stageLog);
log.debug("Created grow entry: {} with stage SEEDLING", saved.getId());
return saved;
}
@Transactional
public GrowEntry advanceStage(UUID entryId, GrowStage newStage) {
GrowEntry entry = getGrowEntry(entryId);
// Close the current stage log
List<GrowStageLog> logs = growStageLogRepository.findByGrowEntryIdOrderByStartedAtAsc(entryId);
if (!logs.isEmpty()) {
GrowStageLog currentLog = logs.get(logs.size() - 1);
if (currentLog.getEndedAt() == null) {
currentLog.setEndedAt(Instant.now());
growStageLogRepository.save(currentLog);
}
}
// Create new stage log
GrowStageLog newLog = new GrowStageLog();
newLog.setGrowEntryId(entryId);
newLog.setStage(newStage);
newLog.setStartedAt(Instant.now());
growStageLogRepository.save(newLog);
// Update entry status
entry.setStatus(newStage);
log.debug("Advanced grow entry {} to stage {}", entryId, newStage);
return growEntryRepository.save(entry);
}
@Transactional
public SensorReading addSensorReading(UUID entryId, SensorReadingType type, BigDecimal value, String unit) {
// Verify entry exists
getGrowEntry(entryId);
SensorReading reading = new SensorReading();
reading.setGrowEntryId(entryId);
reading.setReadingType(type);
reading.setValue(value);
reading.setUnit(unit);
reading.setRecordedAt(Instant.now());
return sensorReadingRepository.save(reading);
}
@Transactional
public GrowPhoto addPhoto(UUID entryId, String filePath, String caption) {
getGrowEntry(entryId);
GrowPhoto photo = new GrowPhoto();
photo.setGrowEntryId(entryId);
photo.setFilePath(filePath);
photo.setCaption(caption);
photo.setTakenAt(Instant.now());
return growPhotoRepository.save(photo);
}
@Transactional
public FeedingLog addFeedingLog(UUID entryId, String nutrientName, BigDecimal amountMl,
BigDecimal waterLiters, BigDecimal phAfter, BigDecimal ecAfter, String notes) {
getGrowEntry(entryId);
FeedingLog feeding = new FeedingLog();
feeding.setGrowEntryId(entryId);
feeding.setNutrientName(nutrientName);
feeding.setAmountMl(amountMl);
feeding.setWaterLiters(waterLiters);
feeding.setPhAfter(phAfter);
feeding.setEcAfter(ecAfter);
feeding.setFedAt(Instant.now());
feeding.setNotes(notes);
return feedingLogRepository.save(feeding);
}
@Transactional
public GrowEntry completeHarvest(UUID entryId, BigDecimal grams, UUID batchId) {
GrowEntry entry = getGrowEntry(entryId);
if (batchId != null) {
batchRepository.findById(batchId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Batch not found"));
entry.setLinkedBatchId(batchId);
}
entry.setHarvestedGrams(grams);
entry.setActualHarvestAt(Instant.now());
entry.setStatus(GrowStage.COMPLETE);
// Close stage log
List<GrowStageLog> logs = growStageLogRepository.findByGrowEntryIdOrderByStartedAtAsc(entryId);
if (!logs.isEmpty()) {
GrowStageLog currentLog = logs.get(logs.size() - 1);
if (currentLog.getEndedAt() == null) {
currentLog.setEndedAt(Instant.now());
growStageLogRepository.save(currentLog);
}
}
// Create COMPLETE stage log
GrowStageLog completeLog = new GrowStageLog();
completeLog.setGrowEntryId(entryId);
completeLog.setStage(GrowStage.COMPLETE);
completeLog.setStartedAt(Instant.now());
growStageLogRepository.save(completeLog);
log.debug("Completed harvest for grow entry {}: {}g", entryId, grams);
return growEntryRepository.save(entry);
}
@Transactional(readOnly = true)
public List<GrowStageLog> getStageLogs(UUID entryId) {
return growStageLogRepository.findByGrowEntryIdOrderByStartedAtAsc(entryId);
}
@Transactional(readOnly = true)
public List<SensorReading> getSensorReadings(UUID entryId) {
return sensorReadingRepository.findByGrowEntryIdOrderByRecordedAtDesc(entryId);
}
@Transactional(readOnly = true)
public List<GrowPhoto> getPhotos(UUID entryId) {
return growPhotoRepository.findByGrowEntryIdOrderByTakenAtDesc(entryId);
}
@Transactional(readOnly = true)
public List<FeedingLog> getFeedingLogs(UUID entryId) {
return feedingLogRepository.findByGrowEntryIdOrderByFedAtDesc(entryId);
}
}
@@ -0,0 +1,11 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.FeedingLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface FeedingLogRepository extends JpaRepository<FeedingLog, UUID> {
List<FeedingLog> findByGrowEntryIdOrderByFedAtDesc(UUID growEntryId);
}
@@ -0,0 +1,11 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.GrowEntry;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface GrowEntryRepository extends JpaRepository<GrowEntry, UUID> {
List<GrowEntry> findAllByOrderByStartedAtDesc();
}
@@ -0,0 +1,11 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.GrowPhoto;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface GrowPhotoRepository extends JpaRepository<GrowPhoto, UUID> {
List<GrowPhoto> findByGrowEntryIdOrderByTakenAtDesc(UUID growEntryId);
}
@@ -0,0 +1,11 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.GrowStageLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface GrowStageLogRepository extends JpaRepository<GrowStageLog, UUID> {
List<GrowStageLog> findByGrowEntryIdOrderByStartedAtAsc(UUID growEntryId);
}
@@ -0,0 +1,11 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.SensorReading;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface SensorReadingRepository extends JpaRepository<SensorReading, UUID> {
List<SensorReading> findByGrowEntryIdOrderByRecordedAtDesc(UUID growEntryId);
}