Merge branch 'feature-farm-setup' into feature-inventory

This commit is contained in:
THIS ONE IS A LITTLE BIT TRICKY KRUB 2025-04-03 18:06:08 +07:00
commit 01bc37a136
5 changed files with 202 additions and 43 deletions

View File

@ -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)

View File

@ -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 {

View File

@ -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
}

View 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
}

View File

@ -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");