mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-18 13:34:08 +01:00
Merge branch 'feature-farm-setup' into feature-inventory
This commit is contained in:
commit
01bc37a136
@ -16,6 +16,7 @@ import (
|
||||
"github.com/forfarm/backend/internal/config"
|
||||
"github.com/forfarm/backend/internal/event"
|
||||
"github.com/forfarm/backend/internal/repository"
|
||||
"github.com/forfarm/backend/internal/services"
|
||||
"github.com/forfarm/backend/internal/workers"
|
||||
)
|
||||
|
||||
@ -46,7 +47,10 @@ func APICmd(ctx context.Context) *cobra.Command {
|
||||
defer eventBus.Close()
|
||||
logger.Info("connected to event bus", "url", config.RABBITMQ_URL)
|
||||
|
||||
analyticsRepo := repository.NewPostgresFarmAnalyticsRepository(pool, logger)
|
||||
logger.Info("starting AnalyticService worker for farm-crop analytics")
|
||||
analyticService := services.NewAnalyticsService()
|
||||
|
||||
analyticsRepo := repository.NewPostgresFarmAnalyticsRepository(pool, logger, analyticService)
|
||||
|
||||
farmRepo := repository.NewPostgresFarm(pool)
|
||||
farmRepo.SetEventPublisher(eventBus)
|
||||
|
||||
@ -36,15 +36,15 @@ type CropAnalytics struct {
|
||||
Variety *string `json:"variety,omitempty"`
|
||||
CurrentStatus string `json:"currentStatus"`
|
||||
GrowthStage string `json:"growthStage"`
|
||||
GrowthProgress int `json:"growthProgress"`
|
||||
LandSize float64 `json:"landSize"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
Humidity *float64 `json:"humidity,omitempty"`
|
||||
SoilMoisture *float64 `json:"soilMoisture,omitempty"`
|
||||
Sunlight *float64 `json:"sunlight,omitempty"`
|
||||
WindSpeed *string `json:"windSpeed,omitempty"`
|
||||
Rainfall *string `json:"rainfall,omitempty"`
|
||||
GrowthProgress int `json:"growthProgress"`
|
||||
WindSpeed *float64 `json:"windSpeed,omitempty"`
|
||||
Rainfall *float64 `json:"rainfall,omitempty"` // (maps to RainVolume1h) GrowthProgress int `json:"growthProgress"`
|
||||
NextAction *string `json:"nextAction,omitempty"`
|
||||
NextActionDue *time.Time `json:"nextActionDue,omitempty"`
|
||||
NutrientLevels *struct {
|
||||
|
||||
@ -11,21 +11,27 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/forfarm/backend/internal/services"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type postgresFarmAnalyticsRepository struct {
|
||||
conn Connection
|
||||
logger *slog.Logger
|
||||
conn Connection
|
||||
logger *slog.Logger
|
||||
analyticsService *services.AnalyticsService
|
||||
}
|
||||
|
||||
func NewPostgresFarmAnalyticsRepository(conn Connection, logger *slog.Logger) domain.AnalyticsRepository {
|
||||
func NewPostgresFarmAnalyticsRepository(conn Connection, logger *slog.Logger, analyticsService *services.AnalyticsService) domain.AnalyticsRepository {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if analyticsService == nil {
|
||||
analyticsService = services.NewAnalyticsService()
|
||||
}
|
||||
return &postgresFarmAnalyticsRepository{
|
||||
conn: conn,
|
||||
logger: logger,
|
||||
conn: conn,
|
||||
logger: logger,
|
||||
analyticsService: analyticsService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,13 +137,13 @@ func calculateGrowthProgress(plantedAt time.Time, daysToMaturity *int) int {
|
||||
|
||||
// --- GetCropAnalytics ---
|
||||
|
||||
// GetCropAnalytics retrieves and calculates analytics data for a specific crop.
|
||||
func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context, cropID string) (*domain.CropAnalytics, error) {
|
||||
// Fetch base data from croplands and plants
|
||||
query := `
|
||||
SELECT
|
||||
c.uuid, c.name, c.farm_id, c.status, c.growth_stage, c.land_size, c.updated_at,
|
||||
p.name, p.variety, p.days_to_maturity,
|
||||
c.created_at -- Needed for growth calculation
|
||||
c.created_at -- Planted date proxy
|
||||
FROM
|
||||
croplands c
|
||||
JOIN
|
||||
@ -146,13 +152,13 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context,
|
||||
c.uuid = $1
|
||||
`
|
||||
|
||||
var analytics domain.CropAnalytics
|
||||
var analytics domain.CropAnalytics // Initialize the struct to be populated
|
||||
var plantName string
|
||||
var variety sql.NullString
|
||||
var daysToMaturity sql.NullInt32
|
||||
var plantedAt time.Time // Use cropland created_at as planted date proxy
|
||||
var plantedAt time.Time
|
||||
var croplandLastUpdated time.Time // Capture cropland specific update time
|
||||
|
||||
// Use r.conn here
|
||||
err := r.conn.QueryRow(ctx, query, cropID).Scan(
|
||||
&analytics.CropID,
|
||||
&analytics.CropName,
|
||||
@ -160,7 +166,7 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context,
|
||||
&analytics.CurrentStatus,
|
||||
&analytics.GrowthStage,
|
||||
&analytics.LandSize,
|
||||
&analytics.LastUpdated,
|
||||
&croplandLastUpdated, // Use this for action suggestion timing
|
||||
&plantName,
|
||||
&variety,
|
||||
&daysToMaturity,
|
||||
@ -168,20 +174,25 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { // Check pgx error too
|
||||
r.logger.Warn("Crop analytics query returned no rows", "crop_id", cropID)
|
||||
// ... (error handling as before) ...
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
|
||||
r.logger.Warn("Crop analytics base data query returned no rows", "crop_id", cropID)
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
r.logger.Error("Failed to query crop analytics data", "crop_id", cropID, "error", err)
|
||||
return nil, fmt.Errorf("database query failed for crop analytics: %w", err)
|
||||
r.logger.Error("Failed to query crop base data", "crop_id", cropID, "error", err)
|
||||
return nil, fmt.Errorf("database query failed for crop base data: %w", err)
|
||||
}
|
||||
|
||||
// --- Populate direct fields ---
|
||||
analytics.PlantName = plantName
|
||||
if variety.Valid {
|
||||
analytics.Variety = &variety.String
|
||||
}
|
||||
analytics.LastUpdated = time.Now().UTC() // Set analytics generation time
|
||||
|
||||
// Calculate Growth Progress
|
||||
// --- Calculate/Fetch derived fields using the service ---
|
||||
|
||||
// Growth Progress
|
||||
var maturityDaysPtr *int
|
||||
if daysToMaturity.Valid {
|
||||
maturityInt := int(daysToMaturity.Int32)
|
||||
@ -189,31 +200,27 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context,
|
||||
}
|
||||
analytics.GrowthProgress = calculateGrowthProgress(plantedAt, maturityDaysPtr)
|
||||
|
||||
// Fetch Farm Analytics to get weather
|
||||
farmAnalytics, err := r.GetFarmAnalytics(ctx, analytics.FarmID) // Call using r receiver
|
||||
if err == nil && farmAnalytics != nil && farmAnalytics.Weather != nil {
|
||||
analytics.Temperature = farmAnalytics.Weather.TempCelsius
|
||||
analytics.Humidity = farmAnalytics.Weather.Humidity
|
||||
windSpeedStr := fmt.Sprintf("%.2f", *farmAnalytics.Weather.WindSpeed)
|
||||
analytics.WindSpeed = &windSpeedStr
|
||||
if farmAnalytics.Weather.RainVolume1h != nil {
|
||||
rainStr := fmt.Sprintf("%.2f", *farmAnalytics.Weather.RainVolume1h)
|
||||
analytics.Rainfall = &rainStr
|
||||
}
|
||||
analytics.Sunlight = nil // Placeholder - requires source
|
||||
analytics.SoilMoisture = nil // Placeholder - requires source
|
||||
} else if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||
r.logger.Warn("Could not fetch farm analytics for weather data", "farm_id", analytics.FarmID, "error", err)
|
||||
// Environmental Data (includes placeholders)
|
||||
farmAnalytics, farmErr := r.GetFarmAnalytics(ctx, analytics.FarmID)
|
||||
if farmErr != nil && !errors.Is(farmErr, domain.ErrNotFound) {
|
||||
r.logger.Warn("Could not fetch associated farm analytics for crop context", "farm_id", analytics.FarmID, "crop_id", cropID, "error", farmErr)
|
||||
// Proceed without farm-level weather data if farm analytics fetch fails
|
||||
}
|
||||
analytics.Temperature, analytics.Humidity, analytics.WindSpeed, analytics.Rainfall, analytics.Sunlight, analytics.SoilMoisture = r.analyticsService.GetEnvironmentalData(farmAnalytics)
|
||||
|
||||
// Placeholder for other fields
|
||||
analytics.PlantHealth = new(string) // Initialize pointer
|
||||
*analytics.PlantHealth = "good" // Default/placeholder value
|
||||
analytics.NextAction = nil
|
||||
analytics.NextActionDue = nil
|
||||
analytics.NutrientLevels = nil // Needs dedicated data source
|
||||
// Plant Health (Dummy)
|
||||
health := r.analyticsService.CalculatePlantHealth(analytics.CurrentStatus, analytics.GrowthStage)
|
||||
analytics.PlantHealth = &health
|
||||
|
||||
r.logger.Debug("Successfully retrieved crop analytics", "crop_id", cropID, "farm_id", analytics.FarmID)
|
||||
// Next Action (Dummy)
|
||||
analytics.NextAction, analytics.NextActionDue = r.analyticsService.SuggestNextAction(analytics.GrowthStage, croplandLastUpdated) // Use cropland update time
|
||||
|
||||
// Nutrient Levels (Dummy)
|
||||
analytics.NutrientLevels = r.analyticsService.GetNutrientLevels(analytics.CropID)
|
||||
|
||||
// --- End Service Usage ---
|
||||
|
||||
r.logger.Debug("Successfully constructed crop analytics", "crop_id", cropID)
|
||||
return &analytics, nil
|
||||
}
|
||||
|
||||
|
||||
148
backend/internal/services/analytics_service.go
Normal file
148
backend/internal/services/analytics_service.go
Normal file
@ -0,0 +1,148 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
)
|
||||
|
||||
// AnalyticsService provides methods for calculating or deriving analytics data.
|
||||
// For now, it contains dummy implementations.
|
||||
type AnalyticsService struct {
|
||||
// Add dependencies like repositories if needed for real logic later
|
||||
}
|
||||
|
||||
// NewAnalyticsService creates a new AnalyticsService.
|
||||
func NewAnalyticsService() *AnalyticsService {
|
||||
return &AnalyticsService{}
|
||||
}
|
||||
|
||||
// CalculatePlantHealth provides a dummy health status.
|
||||
// TODO: Implement real health calculation based on status, weather, events, etc.
|
||||
func (s *AnalyticsService) CalculatePlantHealth(status string, growthStage string) string {
|
||||
// Simple dummy logic
|
||||
switch status {
|
||||
case "Problem", "Diseased", "Infested":
|
||||
return "warning"
|
||||
case "Fallow", "Harvested":
|
||||
return "n/a" // Or maybe 'good' if fallow is considered healthy state
|
||||
default:
|
||||
// Slightly randomize for demo purposes
|
||||
if rand.Intn(10) < 2 { // 20% chance of warning even if status is 'growing'
|
||||
return "warning"
|
||||
}
|
||||
return "good"
|
||||
}
|
||||
}
|
||||
|
||||
// SuggestNextAction provides a dummy next action based on growth stage.
|
||||
// TODO: Implement real suggestion logic based on stage, weather, history, plant type etc.
|
||||
func (s *AnalyticsService) SuggestNextAction(growthStage string, lastUpdated time.Time) (action *string, dueDate *time.Time) {
|
||||
// Default action
|
||||
nextActionStr := "Monitor crop health"
|
||||
nextDueDate := time.Now().Add(24 * time.Hour) // Check tomorrow
|
||||
|
||||
switch growthStage {
|
||||
case "Planned", "Planting":
|
||||
nextActionStr = "Prepare soil and planting"
|
||||
nextDueDate = time.Now().Add(12 * time.Hour)
|
||||
case "Germination", "Seedling":
|
||||
nextActionStr = "Check for germination success and early pests"
|
||||
nextDueDate = time.Now().Add(48 * time.Hour)
|
||||
case "Vegetative":
|
||||
nextActionStr = "Monitor growth and apply nutrients if needed"
|
||||
nextDueDate = time.Now().Add(72 * time.Hour)
|
||||
case "Flowering", "Budding":
|
||||
nextActionStr = "Check pollination and manage pests/diseases"
|
||||
nextDueDate = time.Now().Add(48 * time.Hour)
|
||||
case "Fruiting", "Ripening":
|
||||
nextActionStr = "Monitor fruit development and prepare for harvest"
|
||||
nextDueDate = time.Now().Add(7 * 24 * time.Hour) // Check in a week
|
||||
case "Harvesting":
|
||||
nextActionStr = "Proceed with harvest"
|
||||
nextDueDate = time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
// Only return if the suggestion is "newer" than the last update to avoid constant same suggestion
|
||||
// This is basic logic, real implementation would be more complex
|
||||
if nextDueDate.After(lastUpdated.Add(1 * time.Hour)) { // Only suggest if due date is >1hr after last update
|
||||
return &nextActionStr, &nextDueDate
|
||||
}
|
||||
|
||||
return nil, nil // No immediate action needed or suggestion is old
|
||||
}
|
||||
|
||||
// GetNutrientLevels provides dummy nutrient levels.
|
||||
// TODO: Implement real nutrient level fetching (e.g., from soil sensors, lab results events).
|
||||
func (s *AnalyticsService) GetNutrientLevels(cropID string) *struct {
|
||||
Nitrogen *float64 `json:"nitrogen,omitempty"`
|
||||
Phosphorus *float64 `json:"phosphorus,omitempty"`
|
||||
Potassium *float64 `json:"potassium,omitempty"`
|
||||
} {
|
||||
// Return dummy data or nil if unavailable
|
||||
if rand.Intn(10) < 7 { // 70% chance of having dummy data
|
||||
n := float64(50 + rand.Intn(40)) // 50-89
|
||||
p := float64(40 + rand.Intn(40)) // 40-79
|
||||
k := float64(45 + rand.Intn(40)) // 45-84
|
||||
return &struct {
|
||||
Nitrogen *float64 `json:"nitrogen,omitempty"`
|
||||
Phosphorus *float64 `json:"phosphorus,omitempty"`
|
||||
Potassium *float64 `json:"potassium,omitempty"`
|
||||
}{
|
||||
Nitrogen: &n,
|
||||
Phosphorus: &p,
|
||||
Potassium: &k,
|
||||
}
|
||||
}
|
||||
return nil // Simulate data not available
|
||||
}
|
||||
|
||||
// GetEnvironmentalData attempts to retrieve relevant environmental data.
|
||||
// TODO: Enhance this - Could query specific weather events for the crop location/timeframe.
|
||||
// Currently relies on potentially stale FarmAnalytics weather.
|
||||
func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalytics) (temp, humidity, wind, rain, sunlight, soilMoisture *float64) {
|
||||
// Initialize with nil
|
||||
temp, humidity, wind, rain, sunlight, soilMoisture = nil, nil, nil, nil, nil, nil
|
||||
|
||||
// Try to get from FarmAnalytics
|
||||
if farmAnalytics != nil && farmAnalytics.Weather != nil {
|
||||
temp = farmAnalytics.Weather.TempCelsius
|
||||
humidity = farmAnalytics.Weather.Humidity
|
||||
wind = farmAnalytics.Weather.WindSpeed
|
||||
rain = farmAnalytics.Weather.RainVolume1h
|
||||
// Note: Sunlight and SoilMoisture are not typically in basic WeatherData
|
||||
}
|
||||
|
||||
// Provide dummy values ONLY if still nil (ensures real data isn't overwritten)
|
||||
if temp == nil {
|
||||
t := float64(18 + rand.Intn(15)) // 18-32 C
|
||||
temp = &t
|
||||
}
|
||||
if humidity == nil {
|
||||
h := float64(40 + rand.Intn(50)) // 40-89 %
|
||||
humidity = &h
|
||||
}
|
||||
if wind == nil {
|
||||
w := float64(rand.Intn(15)) // 0-14 m/s
|
||||
wind = &w
|
||||
}
|
||||
if rain == nil {
|
||||
// Simulate less frequent rain
|
||||
r := 0.0
|
||||
if rand.Intn(10) < 2 { // 20% chance of rain
|
||||
r = float64(rand.Intn(5)) // 0-4 mm
|
||||
}
|
||||
rain = &r
|
||||
}
|
||||
if sunlight == nil {
|
||||
sl := float64(60 + rand.Intn(40)) // 60-99 %
|
||||
sunlight = &sl
|
||||
}
|
||||
if soilMoisture == nil {
|
||||
sm := float64(30 + rand.Intn(50)) // 30-79 %
|
||||
soilMoisture = &sm
|
||||
}
|
||||
|
||||
return // Named return values
|
||||
}
|
||||
@ -22,7 +22,7 @@ export function GoogleSigninButton() {
|
||||
const exchangeRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/oauth/exchange`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ access_token: credentialResponse.credential }),
|
||||
body: JSON.stringify({ accessToken: credentialResponse.credential }),
|
||||
});
|
||||
if (!exchangeRes.ok) {
|
||||
throw new Error("Exchange token request failed");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user