From f4813e31b400ac9d702909f5603369e47eeb7c20 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Wed, 2 Apr 2025 17:40:25 +0700 Subject: [PATCH] feat: Add farm_analytics table and update domain types --- backend/internal/domain/analytics.go | 110 ++++-------- backend/internal/domain/cropland.go | 5 +- backend/internal/domain/farm.go | 20 +-- backend/internal/domain/weather.go | 21 +++ .../00014_create_farm_analytics_table.sql | 159 ++++++++++++++++++ 5 files changed, 230 insertions(+), 85 deletions(-) create mode 100644 backend/internal/domain/weather.go create mode 100644 backend/migrations/00014_create_farm_analytics_table.sql diff --git a/backend/internal/domain/analytics.go b/backend/internal/domain/analytics.go index 505291a..fa98cc6 100644 --- a/backend/internal/domain/analytics.go +++ b/backend/internal/domain/analytics.go @@ -6,84 +6,46 @@ import ( ) type FarmAnalytics struct { - FarmID string - Name string - OwnerID string - LastUpdated time.Time - WeatherData *WeatherAnalytics `json:"weather_data,omitempty"` - InventoryData *InventoryAnalytics `json:"inventory_data,omitempty"` - PlantHealthData *PlantHealthAnalytics `json:"plant_health_data,omitempty"` - FinancialData *FinancialAnalytics `json:"financial_data,omitempty"` - ProductionData *ProductionAnalytics `json:"production_data,omitempty"` + FarmID string `json:"farm_id"` + FarmName string `json:"farm_name"` + OwnerID string `json:"owner_id"` + FarmType *string `json:"farm_type,omitempty"` + TotalSize *string `json:"total_size,omitempty"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Weather *WeatherData + InventoryInfo struct { + TotalItems int `json:"total_items"` + LowStockCount int `json:"low_stock_count"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + } `json:"inventory_info"` + CropInfo struct { + TotalCount int `json:"total_count"` + GrowingCount int `json:"growing_count"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + } `json:"crop_info"` + OverallStatus *string `json:"overall_status,omitempty"` + AnalyticsLastUpdated time.Time `json:"analytics_last_updated"` } -type WeatherAnalytics struct { - LastUpdated time.Time - Temperature float64 - Humidity float64 - Rainfall float64 - WindSpeed float64 - WeatherStatus string - AlertLevel string - ForecastSummary string -} - -type InventoryAnalytics struct { - LastUpdated time.Time - TotalItems int - LowStockItems int - TotalValue float64 - RecentChanges []InventoryChange -} - -type InventoryChange struct { - ItemID string - ItemName string - ChangeAmount float64 - ChangeType string - ChangedAt time.Time -} - -type PlantHealthAnalytics struct { - LastUpdated time.Time - HealthyPlants int - UnhealthyPlants int - CriticalPlants int - RecentHealthIssues []PlantHealthIssue -} - -type PlantHealthIssue struct { - PlantID string - PlantName string - HealthStatus string - AlertLevel string - RecordedAt time.Time -} - -type FinancialAnalytics struct { - LastUpdated time.Time - TotalRevenue float64 - TotalExpenses float64 - NetProfit float64 - RecentTransactions []TransactionSummary -} - -type TransactionSummary struct { - TransactionID string - Type string - Amount float64 - Status string - CreatedAt time.Time -} - -type ProductionAnalytics struct { - LastUpdated time.Time - TotalProduction float64 - YieldRate float64 - HarvestForecast float64 +type CropAnalytics struct { + CropID string `json:"crop_id"` + CropName string `json:"crop_name"` + FarmID string `json:"farm_id"` + PlantName string `json:"plant_name"` + Variety *string `json:"variety,omitempty"` + CurrentStatus string `json:"current_status"` + GrowthStage string `json:"growth_stage"` + LandSize float64 `json:"land_size"` + LastUpdated time.Time `json:"last_updated"` } type AnalyticsRepository interface { GetFarmAnalytics(ctx context.Context, farmID string) (*FarmAnalytics, error) - SaveFarmAnalytics(ctx context.Context, farmID string, data interface{}) error + CreateOrUpdateFarmBaseData(ctx context.Context, farm *Farm) error + UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *WeatherData) error + UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error + UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error + DeleteFarmAnalytics(ctx context.Context, farmID string) error + UpdateFarmOverallStatus(ctx context.Context, farmID string, status string) error } diff --git a/backend/internal/domain/cropland.go b/backend/internal/domain/cropland.go index 2da364d..b15dc06 100644 --- a/backend/internal/domain/cropland.go +++ b/backend/internal/domain/cropland.go @@ -2,6 +2,7 @@ package domain import ( "context" + "encoding/json" "time" validation "github.com/go-ozzo/ozzo-validation/v4" @@ -16,6 +17,7 @@ type Cropland struct { GrowthStage string PlantID string FarmID string + GeoFeature json.RawMessage CreatedAt time.Time UpdatedAt time.Time } @@ -32,7 +34,8 @@ func (c *Cropland) Validate() error { type CroplandRepository interface { GetByID(context.Context, string) (Cropland, error) GetByFarmID(ctx context.Context, farmID string) ([]Cropland, error) - GetAll(ctx context.Context) ([]Cropland, error) // Add this method + GetAll(ctx context.Context) ([]Cropland, error) CreateOrUpdate(context.Context, *Cropland) error Delete(context.Context, string) error + SetEventPublisher(EventPublisher) } diff --git a/backend/internal/domain/farm.go b/backend/internal/domain/farm.go index 4247c09..d349d00 100644 --- a/backend/internal/domain/farm.go +++ b/backend/internal/domain/farm.go @@ -8,16 +8,16 @@ import ( ) type Farm struct { - UUID string - Name string - Lat float64 // single latitude value - Lon float64 // single longitude value - FarmType string // e.g., "Durian", "mango", "mixed-crop", "others" - TotalSize string // e.g., "10 Rai" (optional) - CreatedAt time.Time - UpdatedAt time.Time - OwnerID string - Crops []Cropland + UUID string `json:"uuid"` + Name string `json:"name"` + Lat float64 `json:"latitude"` + Lon float64 `json:"longitude"` + FarmType string `json:"farm_type,omitempty"` + TotalSize string `json:"total_size,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + OwnerID string `json:"owner_id"` + Crops []Cropland `json:"crops,omitempty"` } func (f *Farm) Validate() error { diff --git a/backend/internal/domain/weather.go b/backend/internal/domain/weather.go new file mode 100644 index 0000000..9fb33d2 --- /dev/null +++ b/backend/internal/domain/weather.go @@ -0,0 +1,21 @@ +package domain + +import ( + "context" + "time" +) + +type WeatherData struct { + TempCelsius *float64 `json:"temp_celsius,omitempty"` + Humidity *float64 `json:"humidity,omitempty"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` + WindSpeed *float64 `json:"wind_speed,omitempty"` + RainVolume1h *float64 `json:"rain_volume_1h,omitempty"` + ObservedAt *time.Time `json:"observed_at,omitempty"` + WeatherLastUpdated *time.Time `json:"weather_last_updated,omitempty"` +} + +type WeatherFetcher interface { + GetCurrentWeatherByCoords(ctx context.Context, lat, lon float64) (*WeatherData, error) +} diff --git a/backend/migrations/00014_create_farm_analytics_table.sql b/backend/migrations/00014_create_farm_analytics_table.sql new file mode 100644 index 0000000..dfdc465 --- /dev/null +++ b/backend/migrations/00014_create_farm_analytics_table.sql @@ -0,0 +1,159 @@ +-- +goose Up + +DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view CASCADE; +DROP FUNCTION IF EXISTS public.refresh_farm_analytics_view() CASCADE; +DROP MATERIALIZED VIEW IF EXISTS public.crop_analytics_view CASCADE; +DROP FUNCTION IF EXISTS public.refresh_crop_analytics_view() CASCADE; + +CREATE TABLE public.farm_analytics ( + farm_id UUID PRIMARY KEY NOT NULL, + farm_name TEXT NOT NULL, + owner_id UUID NOT NULL, + farm_type TEXT, + total_size TEXT, + latitude DOUBLE PRECISION NOT NULL, + longitude DOUBLE PRECISION NOT NULL, + + weather_temp_celsius DOUBLE PRECISION, + weather_humidity DOUBLE PRECISION, + weather_description TEXT, + weather_icon TEXT, + weather_wind_speed DOUBLE PRECISION, + weather_rain_1h DOUBLE PRECISION, + weather_observed_at TIMESTAMPTZ, -- Timestamp from the weather data itself + weather_last_updated TIMESTAMPTZ, -- Timestamp when weather was last fetched/updated in this record + + inventory_total_items INT DEFAULT 0 NOT NULL, + inventory_low_stock_count INT DEFAULT 0 NOT NULL, + inventory_last_updated TIMESTAMPTZ, + + crop_total_count INT DEFAULT 0 NOT NULL, + crop_growing_count INT DEFAULT 0 NOT NULL, -- Example: specific status count + crop_last_updated TIMESTAMPTZ, + + overall_status TEXT, -- e.g., 'ok', 'warning', 'critical' - Can be updated by various events + + analytics_last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When this specific analytics record was last touched + + CONSTRAINT fk_farm_analytics_farm FOREIGN KEY (farm_id) REFERENCES public.farms(uuid) ON DELETE CASCADE, + CONSTRAINT fk_farm_analytics_owner FOREIGN KEY (owner_id) REFERENCES public.users(uuid) ON DELETE CASCADE -- Assuming owner_id refers to users.uuid +); + +CREATE INDEX idx_farm_analytics_owner_id ON public.farm_analytics(owner_id); +CREATE INDEX idx_farm_analytics_last_updated ON public.farm_analytics(analytics_last_updated DESC); +CREATE INDEX idx_farm_analytics_weather_last_updated ON public.farm_analytics(weather_last_updated DESC); + +-- Optional: Initial data population (run once after table creation if needed) +-- INSERT INTO public.farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude, analytics_last_updated) +-- SELECT uuid, name, owner_id, farm_type, total_size, lat, lon, updated_at +-- FROM public.farms +-- ON CONFLICT (farm_id) DO NOTHING; + + +-- +goose Down + +DROP TABLE IF EXISTS public.farm_analytics; + +CREATE MATERIALIZED VIEW public.farm_analytics_view AS +SELECT + f.uuid AS farm_id, + f.name AS farm_name, + f.owner_id, + f.farm_type, + f.total_size, + COALESCE( + (SELECT MAX(ae_max.created_at) FROM public.analytics_events ae_max WHERE ae_max.farm_id = f.uuid), + f.updated_at + ) AS last_updated, + ( + SELECT jsonb_build_object( + 'last_updated', latest_weather.created_at, + 'temperature', (latest_weather.event_data->>'temperature')::float, + 'humidity', (latest_weather.event_data->>'humidity')::float, + 'rainfall', (latest_weather.event_data->>'rainfall')::float, + 'wind_speed', (latest_weather.event_data->>'wind_speed')::float, + 'weather_status', latest_weather.event_data->>'weather_status', + 'alert_level', latest_weather.event_data->>'alert_level', + 'forecast_summary', latest_weather.event_data->>'forecast_summary' + ) + FROM ( + SELECT ae_w.event_data, ae_w.created_at + FROM public.analytics_events ae_w + WHERE ae_w.farm_id = f.uuid AND ae_w.event_type = 'weather.updated' + ORDER BY ae_w.created_at DESC + LIMIT 1 + ) AS latest_weather + ) AS weather_data, + ( + SELECT jsonb_build_object( + 'items', COALESCE(jsonb_agg(ae_i.event_data->'items' ORDER BY (ae_i.event_data->>'timestamp') DESC) FILTER (WHERE ae_i.event_data ? 'items'), '[]'::jsonb), + 'last_updated', MAX(ae_i.created_at) + ) + FROM analytics_events ae_i + WHERE ae_i.farm_id = f.uuid AND ae_i.event_type = 'inventory.updated' + ) AS inventory_data, + ( + SELECT jsonb_build_object( + 'status', MAX(ae_p.event_data->>'status'), + 'issues', COALESCE(jsonb_agg(ae_p.event_data->'issues') FILTER (WHERE ae_p.event_data ? 'issues'), '[]'::jsonb), + 'last_updated', MAX(ae_p.created_at) + ) + FROM analytics_events ae_p + WHERE ae_p.farm_id = f.uuid AND ae_p.event_type = 'plant_health.updated' + ) AS plant_health_data, + ( + SELECT jsonb_build_object( + 'yield_total', SUM((ae_pr.event_data->>'yield')::float) FILTER (WHERE ae_pr.event_data ? 'yield'), + 'forecast_latest', MAX(ae_pr.event_data->>'forecast') FILTER (WHERE ae_pr.event_data ? 'forecast'), + 'last_updated', MAX(ae_pr.created_at) + ) + FROM analytics_events ae_pr + WHERE ae_pr.farm_id = f.uuid AND ae_pr.event_type = 'production.updated' + ) AS production_data +FROM + public.farms f; + +CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id); +CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION public.refresh_farm_analytics_view() +RETURNS TRIGGER AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY public.farm_analytics_view; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +CREATE TRIGGER refresh_farm_analytics_view_trigger_events +AFTER INSERT ON public.analytics_events -- Adjust if original trigger was different +FOR EACH STATEMENT +EXECUTE FUNCTION public.refresh_farm_analytics_view(); + +CREATE TRIGGER refresh_farm_analytics_view_trigger_farms +AFTER INSERT OR UPDATE OR DELETE ON public.farms -- Adjust if original trigger was different +FOR EACH STATEMENT +EXECUTE FUNCTION public.refresh_farm_analytics_view(); + +CREATE MATERIALIZED VIEW public.crop_analytics_view AS +SELECT + c.uuid AS crop_id, c.name AS crop_name, c.farm_id, p.name AS plant_name, p.variety AS variety, + c.status AS current_status, c.growth_stage, c.land_size, c.geo_feature, c.updated_at AS last_updated +FROM public.croplands c JOIN public.plants p ON c.plant_id = p.uuid; +CREATE UNIQUE INDEX idx_crop_analytics_view_crop_id ON public.crop_analytics_view(crop_id); +CREATE INDEX idx_crop_analytics_view_farm_id ON public.crop_analytics_view(farm_id); +CREATE INDEX idx_crop_analytics_view_plant_name ON public.crop_analytics_view(plant_name); +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION public.refresh_crop_analytics_view() +RETURNS TRIGGER AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY public.crop_analytics_view; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd +CREATE TRIGGER refresh_crop_analytics_trigger_croplands +AFTER INSERT OR UPDATE OR DELETE ON public.croplands FOR EACH STATEMENT EXECUTE FUNCTION public.refresh_crop_analytics_view(); +CREATE TRIGGER refresh_crop_analytics_trigger_plants +AFTER INSERT OR UPDATE OR DELETE ON public.plants FOR EACH STATEMENT EXECUTE FUNCTION public.refresh_crop_analytics_view(); \ No newline at end of file