mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 14:04:08 +01:00
393 lines
13 KiB
Go
393 lines
13 KiB
Go
// backend/internal/repository/postgres_farm_analytics.go
|
|
package repository
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"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
|
|
analyticsService *services.AnalyticsService
|
|
}
|
|
|
|
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,
|
|
analyticsService: analyticsService,
|
|
}
|
|
}
|
|
|
|
func (r *postgresFarmAnalyticsRepository) GetFarmAnalytics(ctx context.Context, farmID string) (*domain.FarmAnalytics, error) {
|
|
query := `
|
|
SELECT
|
|
farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude,
|
|
weather_temp_celsius, weather_humidity, weather_description, weather_icon,
|
|
weather_wind_speed, weather_rain_1h, weather_observed_at, weather_last_updated,
|
|
inventory_total_items, inventory_low_stock_count, inventory_last_updated,
|
|
crop_total_count, crop_growing_count, crop_last_updated,
|
|
overall_status, analytics_last_updated
|
|
FROM public.farm_analytics
|
|
WHERE farm_id = $1`
|
|
|
|
var analytics domain.FarmAnalytics
|
|
var farmType sql.NullString
|
|
var totalSize sql.NullString
|
|
var weatherJSON, inventoryJSON, cropJSON []byte // Use []byte for JSONB
|
|
var overallStatus sql.NullString
|
|
|
|
err := r.conn.QueryRow(ctx, query, farmID).Scan(
|
|
&analytics.FarmID,
|
|
&analytics.FarmName,
|
|
&analytics.OwnerID,
|
|
&farmType,
|
|
&totalSize,
|
|
&analytics.Latitude,
|
|
&analytics.Longitude,
|
|
&weatherJSON,
|
|
&inventoryJSON,
|
|
&cropJSON,
|
|
&overallStatus,
|
|
&analytics.AnalyticsLastUpdated,
|
|
)
|
|
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
|
|
r.logger.Warn("Farm analytics data not found", "farm_id", farmID)
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
r.logger.Error("Failed to query farm analytics", "farm_id", farmID, "error", err)
|
|
return nil, fmt.Errorf("database query failed for farm analytics: %w", err)
|
|
}
|
|
|
|
// Handle nullable fields
|
|
if farmType.Valid {
|
|
analytics.FarmType = &farmType.String
|
|
}
|
|
if totalSize.Valid {
|
|
analytics.TotalSize = &totalSize.String
|
|
}
|
|
if overallStatus.Valid {
|
|
analytics.OverallStatus = &overallStatus.String
|
|
}
|
|
|
|
// Unmarshal JSONB data
|
|
if weatherJSON != nil {
|
|
if err := json.Unmarshal(weatherJSON, &analytics.Weather); err != nil {
|
|
r.logger.Warn("Failed to unmarshal weather data from farm analytics", "farm_id", farmID, "error", err)
|
|
// Continue, but log the issue
|
|
}
|
|
}
|
|
if inventoryJSON != nil {
|
|
if err := json.Unmarshal(inventoryJSON, &analytics.InventoryInfo); err != nil {
|
|
r.logger.Warn("Failed to unmarshal inventory data from farm analytics", "farm_id", farmID, "error", err)
|
|
}
|
|
}
|
|
if cropJSON != nil {
|
|
if err := json.Unmarshal(cropJSON, &analytics.CropInfo); err != nil {
|
|
r.logger.Warn("Failed to unmarshal crop data from farm analytics", "farm_id", farmID, "error", err)
|
|
}
|
|
}
|
|
|
|
r.logger.Debug("Successfully retrieved farm analytics", "farm_id", farmID)
|
|
return &analytics, nil
|
|
}
|
|
|
|
// --- Calculation Helper ---
|
|
|
|
// calculateGrowthProgress calculates the percentage completion based on planting date and maturity days.
|
|
func calculateGrowthProgress(plantedAt time.Time, daysToMaturity *int) int {
|
|
if daysToMaturity == nil || *daysToMaturity <= 0 {
|
|
return 0 // Cannot calculate if maturity days are unknown or zero
|
|
}
|
|
if plantedAt.IsZero() {
|
|
return 0 // Cannot calculate if planting date is unknown
|
|
}
|
|
|
|
today := time.Now()
|
|
daysElapsed := today.Sub(plantedAt).Hours() / 24
|
|
progress := (daysElapsed / float64(*daysToMaturity)) * 100
|
|
|
|
// Clamp progress between 0 and 100
|
|
if progress < 0 {
|
|
return 0
|
|
}
|
|
if progress > 100 {
|
|
return 100
|
|
}
|
|
return int(progress)
|
|
}
|
|
|
|
// --- GetCropAnalytics ---
|
|
|
|
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 -- Planted date proxy
|
|
FROM
|
|
croplands c
|
|
JOIN
|
|
plants p ON c.plant_id = p.uuid
|
|
WHERE
|
|
c.uuid = $1
|
|
`
|
|
|
|
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
|
|
var croplandLastUpdated time.Time // Capture cropland specific update time
|
|
|
|
err := r.conn.QueryRow(ctx, query, cropID).Scan(
|
|
&analytics.CropID,
|
|
&analytics.CropName,
|
|
&analytics.FarmID,
|
|
&analytics.CurrentStatus,
|
|
&analytics.GrowthStage,
|
|
&analytics.LandSize,
|
|
&croplandLastUpdated, // Use this for action suggestion timing
|
|
&plantName,
|
|
&variety,
|
|
&daysToMaturity,
|
|
&plantedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
// ... (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 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/Fetch derived fields using the service ---
|
|
|
|
// Growth Progress
|
|
var maturityDaysPtr *int
|
|
if daysToMaturity.Valid {
|
|
maturityInt := int(daysToMaturity.Int32)
|
|
maturityDaysPtr = &maturityInt
|
|
}
|
|
analytics.GrowthProgress = calculateGrowthProgress(plantedAt, maturityDaysPtr)
|
|
|
|
// 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)
|
|
|
|
// Plant Health (Dummy)
|
|
health := r.analyticsService.CalculatePlantHealth(analytics.CurrentStatus, analytics.GrowthStage)
|
|
analytics.PlantHealth = &health
|
|
|
|
// 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
|
|
}
|
|
|
|
// --- Implement other AnalyticsRepository methods ---
|
|
|
|
func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context.Context, farm *domain.Farm) error {
|
|
query := `
|
|
INSERT INTO farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude, analytics_last_updated)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (farm_id) DO UPDATE
|
|
SET farm_name = EXCLUDED.farm_name,
|
|
owner_id = EXCLUDED.owner_id,
|
|
farm_type = EXCLUDED.farm_type,
|
|
total_size = EXCLUDED.total_size,
|
|
latitude = EXCLUDED.latitude,
|
|
longitude = EXCLUDED.longitude,
|
|
analytics_last_updated = EXCLUDED.analytics_last_updated;`
|
|
|
|
_, err := r.conn.Exec(ctx, query,
|
|
farm.UUID,
|
|
farm.Name,
|
|
farm.OwnerID,
|
|
farm.FarmType, // Handle potential empty string vs null if needed
|
|
farm.TotalSize, // Handle potential empty string vs null if needed
|
|
farm.Lat,
|
|
farm.Lon,
|
|
time.Now().UTC(), // Update timestamp on change
|
|
)
|
|
if err != nil {
|
|
r.logger.Error("Failed to create/update farm base analytics data", "farm_id", farm.UUID, "error", err)
|
|
return fmt.Errorf("failed to upsert farm base data: %w", err)
|
|
}
|
|
r.logger.Debug("Upserted farm base analytics data", "farm_id", farm.UUID)
|
|
return nil
|
|
}
|
|
|
|
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *domain.WeatherData) error {
|
|
if weatherData == nil {
|
|
return errors.New("weather data cannot be nil for update")
|
|
}
|
|
query := `
|
|
UPDATE public.farm_analytics SET
|
|
weather_temp_celsius = $2,
|
|
weather_humidity = $3,
|
|
weather_description = $4,
|
|
weather_icon = $5,
|
|
weather_wind_speed = $6,
|
|
weather_rain_1h = $7,
|
|
weather_observed_at = $8,
|
|
weather_last_updated = NOW(), -- Use current time for the update time
|
|
analytics_last_updated = NOW()
|
|
WHERE farm_id = $1`
|
|
|
|
_, err := r.conn.Exec(ctx, query,
|
|
farmID,
|
|
weatherData.TempCelsius,
|
|
weatherData.Humidity,
|
|
weatherData.Description,
|
|
weatherData.Icon,
|
|
weatherData.WindSpeed,
|
|
weatherData.RainVolume1h,
|
|
weatherData.ObservedAt,
|
|
)
|
|
if err != nil {
|
|
r.logger.Error("Error updating farm weather analytics", "farm_id", farmID, "error", err)
|
|
return fmt.Errorf("failed to update weather analytics for farm %s: %w", farmID, err)
|
|
}
|
|
r.logger.Debug("Updated farm weather analytics", "farm_id", farmID)
|
|
return nil
|
|
}
|
|
|
|
// UpdateFarmAnalyticsCropStats needs to query the croplands table for the farm
|
|
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error {
|
|
countQuery := `
|
|
SELECT
|
|
COUNT(*),
|
|
COUNT(*) FILTER (WHERE lower(status) = 'growing')
|
|
FROM public.croplands
|
|
WHERE farm_id = $1
|
|
`
|
|
var totalCount, growingCount int
|
|
err := r.conn.QueryRow(ctx, countQuery, farmID).Scan(&totalCount, &growingCount)
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
r.logger.Error("Error calculating crop counts", "farm_id", farmID, "error", err)
|
|
return fmt.Errorf("failed to calculate crop stats for farm %s: %w", farmID, err)
|
|
}
|
|
}
|
|
|
|
updateQuery := `
|
|
UPDATE public.farm_analytics SET
|
|
crop_total_count = $2,
|
|
crop_growing_count = $3,
|
|
crop_last_updated = NOW(),
|
|
analytics_last_updated = NOW()
|
|
WHERE farm_id = $1`
|
|
|
|
cmdTag, err := r.conn.Exec(ctx, updateQuery, farmID, totalCount, growingCount)
|
|
if err != nil {
|
|
r.logger.Error("Error updating farm crop stats", "farm_id", farmID, "error", err)
|
|
return fmt.Errorf("failed to update crop stats for farm %s: %w", farmID, err)
|
|
}
|
|
if cmdTag.RowsAffected() == 0 {
|
|
r.logger.Warn("No farm analytics record found to update crop stats", "farm_id", farmID)
|
|
// Optionally, create the base record here if it should always exist
|
|
return r.CreateOrUpdateFarmBaseData(ctx, &domain.Farm{UUID: farmID /* Fetch other details */})
|
|
}
|
|
|
|
r.logger.Debug("Updated farm crop stats", "farm_id", farmID, "total", totalCount, "growing", growingCount)
|
|
return nil
|
|
}
|
|
|
|
// UpdateFarmAnalyticsInventoryStats needs to query inventory_items
|
|
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error {
|
|
query := `
|
|
UPDATE public.farm_analytics SET
|
|
-- inventory_total_items = (SELECT COUNT(*) FROM ... WHERE farm_id = $1), -- Example future logic
|
|
-- inventory_low_stock_count = (SELECT COUNT(*) FROM ... WHERE farm_id = $1 AND status = 'low'), -- Example
|
|
inventory_last_updated = NOW(),
|
|
analytics_last_updated = NOW()
|
|
WHERE farm_id = $1`
|
|
|
|
cmdTag, err := r.conn.Exec(ctx, query, farmID)
|
|
if err != nil {
|
|
r.logger.Error("Error touching inventory timestamp in farm analytics", "farm_id", farmID, "error", err)
|
|
return fmt.Errorf("failed to update inventory stats timestamp for farm %s: %w", farmID, err)
|
|
}
|
|
if cmdTag.RowsAffected() == 0 {
|
|
r.logger.Warn("No farm analytics record found to update inventory timestamp", "farm_id", farmID)
|
|
}
|
|
|
|
r.logger.Debug("Updated farm inventory timestamp", "farm_id", farmID)
|
|
return nil
|
|
}
|
|
|
|
func (r *postgresFarmAnalyticsRepository) DeleteFarmAnalytics(ctx context.Context, farmID string) error {
|
|
query := `DELETE FROM farm_analytics WHERE farm_id = $1;`
|
|
cmdTag, err := r.conn.Exec(ctx, query, farmID)
|
|
if err != nil {
|
|
r.logger.Error("Failed to delete farm analytics data", "farm_id", farmID, "error", err)
|
|
return fmt.Errorf("database delete failed for farm analytics: %w", err)
|
|
}
|
|
if cmdTag.RowsAffected() == 0 {
|
|
r.logger.Warn("No farm analytics record found to delete", "farm_id", farmID)
|
|
// Return ErrNotFound if it's important to know it wasn't there
|
|
return domain.ErrNotFound
|
|
}
|
|
r.logger.Info("Deleted farm analytics data", "farm_id", farmID)
|
|
return nil
|
|
}
|
|
|
|
func (r *postgresFarmAnalyticsRepository) UpdateFarmOverallStatus(ctx context.Context, farmID string, status string) error {
|
|
query := `
|
|
UPDATE public.farm_analytics SET
|
|
overall_status = $2,
|
|
analytics_last_updated = NOW()
|
|
WHERE farm_id = $1`
|
|
|
|
cmdTag, err := r.conn.Exec(ctx, query, farmID, status)
|
|
if err != nil {
|
|
r.logger.Error("Error updating farm overall status", "farm_id", farmID, "status", status, "error", err)
|
|
return fmt.Errorf("failed to update overall status for farm %s: %w", farmID, err)
|
|
}
|
|
if cmdTag.RowsAffected() == 0 {
|
|
r.logger.Warn("No farm analytics record found to update overall status", "farm_id", farmID)
|
|
}
|
|
r.logger.Debug("Updated farm overall status", "farm_id", farmID, "status", status)
|
|
return nil
|
|
}
|