From 525ff650f2a32ab3d12afff118228c4d5dc5ec06 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 15:40:47 +0700 Subject: [PATCH 1/8] feat(BE-390): calculation dashboard --- internal/entities/dashboard.go | 18 + .../controllers/dashboard.controller.go | 193 ++++ .../modules/dashboards/dto/dashboard.dto.go | 82 ++ internal/modules/dashboards/module.go | 26 + .../repositories/dashboard.repository.go | 44 + .../dashboard_stats.repository.go | 672 ++++++++++++++ internal/modules/dashboards/route.go | 26 + .../dashboards/services/dashboard.service.go | 867 ++++++++++++++++++ .../validations/dashboard.validation.go | 54 ++ internal/response/response.go | 8 + internal/route/route.go | 2 + 11 files changed, 1992 insertions(+) create mode 100644 internal/entities/dashboard.go create mode 100644 internal/modules/dashboards/controllers/dashboard.controller.go create mode 100644 internal/modules/dashboards/dto/dashboard.dto.go create mode 100644 internal/modules/dashboards/module.go create mode 100644 internal/modules/dashboards/repositories/dashboard.repository.go create mode 100644 internal/modules/dashboards/repositories/dashboard_stats.repository.go create mode 100644 internal/modules/dashboards/route.go create mode 100644 internal/modules/dashboards/services/dashboard.service.go create mode 100644 internal/modules/dashboards/validations/dashboard.validation.go diff --git a/internal/entities/dashboard.go b/internal/entities/dashboard.go new file mode 100644 index 00000000..ab9f4ea5 --- /dev/null +++ b/internal/entities/dashboard.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Dashboard struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/modules/dashboards/controllers/dashboard.controller.go b/internal/modules/dashboards/controllers/dashboard.controller.go new file mode 100644 index 00000000..47e8d5bc --- /dev/null +++ b/internal/modules/dashboards/controllers/dashboard.controller.go @@ -0,0 +1,193 @@ +package controller + +import ( + "math" + "strconv" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type DashboardController struct { + DashboardService service.DashboardService +} + +func NewDashboardController(dashboardService service.DashboardService) *DashboardController { + return &DashboardController{ + DashboardService: dashboardService, + } +} + +func (u *DashboardController) GetAll(c *fiber.Ctx) error { + parseStringListParam := func(param string) ([]string, error) { + if param == "" { + return nil, nil + } + parts := strings.Split(param, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + return nil, strconv.ErrSyntax + } + result = append(result, trimmed) + } + return result, nil + } + + parseUintListParam := func(param string) ([]uint, error) { + if param == "" { + return nil, nil + } + parts := strings.Split(param, ",") + ids := make([]uint, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + return nil, strconv.ErrSyntax + } + parsed, err := strconv.ParseUint(trimmed, 10, 64) + if err != nil { + return nil, err + } + ids = append(ids, uint(parsed)) + } + return ids, nil + } + + lokasiIds, err := parseUintListParam(c.Query("lokasi_ids", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid lokasi_ids") + } + + flockIds, err := parseUintListParam(c.Query("flock_ids", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid flock_ids") + } + + kandangIds, err := parseUintListParam(c.Query("kandang_ids", "")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_ids") + } + + include, err := parseStringListParam(strings.ToLower(c.Query("include", ""))) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid include") + } + + analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview))) + metric := strings.ToLower(strings.TrimSpace(c.Query("metric", ""))) + + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: strings.TrimSpace(c.Query("search", "")), + PerformanceOverviewFilter: validation.PerformanceOverviewFilter{ + StartDate: c.Query("start_date", ""), + EndDate: c.Query("end_date", ""), + AnalysisMode: analysisMode, + ComparisonType: strings.ToUpper(strings.TrimSpace(c.Query("comparison_type", ""))), + Metric: metric, + LokasiIds: lokasiIds, + FlockIds: flockIds, + KandangIds: kandangIds, + Include: include, + }, + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if query.AnalysisMode == validation.AnalysisModeComparison && query.ComparisonType == "" { + return fiber.NewError(fiber.StatusBadRequest, "comparison_type is required for comparison mode") + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + + startDate, endDate, endExclusive, err := parsePeriodDates(query.StartDate, query.EndDate, location) + if err != nil { + return err + } + + query.PeriodStart = startDate + query.PeriodEnd = endDate + query.PeriodEndExclusive = endExclusive + + result, totalResults, err := u.DashboardService.GetAll(c.Context(), query) + if err != nil { + return err + } + + filters := dto.DashboardFiltersDTO{ + StartDate: query.StartDate, + EndDate: query.EndDate, + AnalysisMode: query.AnalysisMode, + ComparisonType: query.ComparisonType, + Metric: query.Metric, + LokasiIds: defaultUintSlice(query.LokasiIds), + FlockIds: defaultUintSlice(query.FlockIds), + KandangIds: defaultUintSlice(query.KandangIds), + Include: query.Include, + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithMeta{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get dashboard successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + Filters: filters, + }, + Data: result, + }) +} + +func defaultUintSlice(values []uint) []uint { + if values == nil { + return []uint{} + } + return values +} + +func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) { + now := time.Now().In(location) + startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) + endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) + + if startDateRaw != "" { + parsed, err := time.ParseInLocation("2006-01-02", startDateRaw, location) + if err != nil { + return time.Time{}, time.Time{}, time.Time{}, fiber.NewError(fiber.StatusBadRequest, "start_date must follow format YYYY-MM-DD") + } + startDate = parsed + } + + if endDateRaw != "" { + parsed, err := time.ParseInLocation("2006-01-02", endDateRaw, location) + if err != nil { + return time.Time{}, time.Time{}, time.Time{}, fiber.NewError(fiber.StatusBadRequest, "end_date must follow format YYYY-MM-DD") + } + endDate = parsed + } + + if endDate.Before(startDate) { + return time.Time{}, time.Time{}, time.Time{}, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date") + } + + endExclusive := endDate.AddDate(0, 0, 1) + return startDate, endDate, endExclusive, nil +} diff --git a/internal/modules/dashboards/dto/dashboard.dto.go b/internal/modules/dashboards/dto/dashboard.dto.go new file mode 100644 index 00000000..bf7d0f91 --- /dev/null +++ b/internal/modules/dashboards/dto/dashboard.dto.go @@ -0,0 +1,82 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type DashboardListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DashboardDetailDTO struct { + DashboardListDTO +} + +type DashboardFiltersDTO struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + AnalysisMode string `json:"analysis_mode"` + ComparisonType string `json:"comparison_type,omitempty"` + Metric string `json:"metric,omitempty"` + LokasiIds []uint `json:"lokasi_ids"` + FlockIds []uint `json:"flock_ids"` + KandangIds []uint `json:"kandang_ids"` + Include []string `json:"include,omitempty"` +} + +type DashboardStatisticsDTO struct { + Label string `json:"label"` + Value float64 `json:"value"` + PercentLastMonth float64 `json:"percent_last_month"` +} + +type DashboardPerformanceOverviewDTO struct { + StatisticsData []DashboardStatisticsDTO `json:"statistics_data"` + Charts map[string]DashboardChartDTO `json:"charts,omitempty"` +} + +type DashboardChartSeriesDTO struct { + Id string `json:"id"` + Label string `json:"label"` + Unit string `json:"unit,omitempty"` +} + +type DashboardChartDTO struct { + Series []DashboardChartSeriesDTO `json:"series"` + Dataset []map[string]interface{} `json:"dataset"` +} + +// === Mapper Functions === + +func ToDashboardListDTO(e entity.Dashboard) DashboardListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return DashboardListDTO{ + Id: e.Id, + Name: e.Name, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToDashboardListDTOs(e []entity.Dashboard) []DashboardListDTO { + result := make([]DashboardListDTO, len(e)) + for i, r := range e { + result[i] = ToDashboardListDTO(r) + } + return result +} diff --git a/internal/modules/dashboards/module.go b/internal/modules/dashboards/module.go new file mode 100644 index 00000000..24574dc7 --- /dev/null +++ b/internal/modules/dashboards/module.go @@ -0,0 +1,26 @@ +package dashboards + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" + sDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type DashboardModule struct{} + +func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + dashboardRepo := rDashboard.NewDashboardRepository(db) + userRepo := rUser.NewUserRepository(db) + + dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + DashboardRoutes(router, userService, dashboardService) +} + diff --git a/internal/modules/dashboards/repositories/dashboard.repository.go b/internal/modules/dashboards/repositories/dashboard.repository.go new file mode 100644 index 00000000..90ee3bf8 --- /dev/null +++ b/internal/modules/dashboards/repositories/dashboard.repository.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gorm.io/gorm" +) + +type DashboardRepository interface { + repository.BaseRepository[entity.Dashboard] + GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) + SumDepletions(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumInitialPopulation(ctx context.Context, endDate time.Time, filters *validation.DashboardFilter) (float64, error) + SumSapronakCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumBopCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumEkspedisiCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error) + SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) + GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) + GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) + GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error) + GetStandardFcrWeekly(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyFcrMetric, error) + GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) + GetComparisonWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType, metric string) ([]ComparisonWeeklyMetric, error) + GetComparisonWeeklyUniformityMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonUniformityMetric, error) + GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) + GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) + GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) +} + +type DashboardRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Dashboard] +} + +func NewDashboardRepository(db *gorm.DB) DashboardRepository { + return &DashboardRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Dashboard](db), + } +} diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go new file mode 100644 index 00000000..a06fdb71 --- /dev/null +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -0,0 +1,672 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +type SellingPriceAggregate struct { + TotalPrice float64 + TotalWeight float64 +} + +type FeedUsageByUom struct { + TotalQty float64 + UomName string +} + +type RecordingWeeklyMetric struct { + Week int + HandDay float64 + EggWeight float64 + FeedIntake float64 + FcrValue float64 + CumDepletionRate float64 +} + +type UniformityWeeklyMetric struct { + Week int + Uniformity float64 + AverageWeight float64 +} + +type StandardWeeklyMetric struct { + Week int + StdLaying float64 + StdEggWeight float64 + StdFeedIntake float64 + StdUniformity float64 + StdDepletion float64 + StdBodyWeight float64 +} + +type StandardWeeklyFcrMetric struct { + Week int + StdFcr float64 +} + +type ComparisonSeries struct { + Id uint + Label string +} + +type ComparisonWeeklyMetric struct { + Week int + SeriesId uint + Value float64 +} + +type ComparisonUniformityMetric struct { + Week int + SeriesId uint + Uniformity float64 + AverageWeight float64 +} + +type EggQualityWeeklyMetric struct { + Week int + NormalQty float64 + AbnormalQty float64 + TotalQty float64 +} + +type WeeklyEggWeightMetric struct { + Week int + EggWeightGrams float64 +} + +type WeeklyFeedUsageMetric struct { + Week int + TotalQty float64 + UomName string +} + +func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *gorm.DB { + if filters == nil { + return db + } + if len(filters.FlockIds) > 0 { + db = db.Where("pfk.project_flock_id IN ?", filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + db = db.Where("k.id IN ?", filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + db = db.Where("k.location_id IN ?", filters.LokasiIds) + } + return db +} + +func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) { + var rows []RecordingWeeklyMetric + + db := r.DB().WithContext(ctx). + Table("recordings AS r"). + Select(`((r.day - 1) / 7 + 1) AS week, + COALESCE(AVG(r.hen_day), 0) AS hand_day, + COALESCE(AVG(r.egg_weight), 0) AS egg_weight, + COALESCE(AVG(r.feed_intake), 0) AS feed_intake, + COALESCE(AVG(r.fcr_value), 0) AS fcr_value, + COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) { + var rows []UniformityWeeklyMetric + + db := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity AS u"). + Select(`u.week AS week, + COALESCE(AVG(u.uniformity), 0) AS uniformity, + COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("u.uniform_date IS NOT NULL"). + Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if err := db.Group("u.week").Order("u.week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error) { + if len(weeks) == 0 { + return nil, nil + } + + standardIDs := r.standardIDSubquery(filters) + if standardIDs == nil { + return nil, nil + } + + var rows []StandardWeeklyMetric + db := r.DB().WithContext(ctx). + Table("standard_growth_details AS sgd"). + Select(`sgd.week AS week, + COALESCE(AVG(psd.target_hen_day_production), 0) AS std_laying, + COALESCE(AVG(psd.target_egg_weight), 0) AS std_egg_weight, + COALESCE(AVG(sgd.feed_intake), 0) AS std_feed_intake, + COALESCE(AVG(sgd.min_uniformity), 0) AS std_uniformity, + COALESCE(AVG(sgd.max_depletion), 0) AS std_depletion, + COALESCE(AVG(sgd.target_mean_bw), 0) AS std_body_weight`). + Joins("LEFT JOIN production_standard_details AS psd ON psd.production_standard_id = sgd.production_standard_id AND psd.week = sgd.week"). + Where("sgd.week IN ?", weeks). + Where("sgd.production_standard_id IN (?)", standardIDs) + + if err := db.Group("sgd.week").Order("sgd.week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyFcrMetric, error) { + if len(weeks) == 0 { + return nil, nil + } + + source := r.standardSourceSubquery(filters) + if source == nil { + return nil, nil + } + + var rows []StandardWeeklyFcrMetric + db := r.DB().WithContext(ctx). + Table("standard_growth_details AS sgd"). + Select(` + sgd.week AS week, + COALESCE(AVG( + COALESCE( + ( + SELECT fs.fcr_number + FROM fcr_standards fs + WHERE fs.fcr_id = src.fcr_id + AND fs.weight >= CASE WHEN sgd.target_mean_bw > 10 THEN sgd.target_mean_bw / 1000 ELSE sgd.target_mean_bw END + ORDER BY fs.weight ASC + LIMIT 1 + ), + ( + SELECT fs.fcr_number + FROM fcr_standards fs + WHERE fs.fcr_id = src.fcr_id + ORDER BY fs.weight DESC + LIMIT 1 + ) + ) + ), 0) AS std_fcr`). + Joins("JOIN (?) AS src ON src.production_standard_id = sgd.production_standard_id", source). + Where("sgd.week IN ?", weeks) + + if err := db.Group("sgd.week").Order("sgd.week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("recording_eggs AS re"). + Select("COALESCE(SUM(re.qty * re.weight), 0)"). + Joins("JOIN recordings AS r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + grams, err := r.SumEggProductionWeightGrams(ctx, start, end, filters) + if err != nil { + return 0, err + } + return grams / 1000, nil +} + +func (r *DashboardRepositoryImpl) GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) { + var rows []FeedUsageByUom + + db := r.DB().WithContext(ctx). + Table("recording_stocks AS rs"). + Select("COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, LOWER(uoms.name) AS uom_name"). + Joins("JOIN recordings AS r ON r.id = rs.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN uoms ON uoms.id = p.uom_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ? AND UPPER(f.name) = ?", entity.FlagableTypeProduct, "PAKAN"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("LOWER(uoms.name)").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) SumDepletions(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("recording_depletions AS rd"). + Select("COALESCE(SUM(rd.qty), 0)"). + Joins("JOIN recordings AS r ON r.id = rd.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumInitialPopulation(ctx context.Context, endDate time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + endOfDate := endDate.AddDate(0, 0, 1) + + db := r.DB().WithContext(ctx). + Table("project_chickins AS pc"). + Select("COALESCE(SUM(pc.usage_qty), 0)"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pc.chick_in_date < ?", endOfDate). + Where("pc.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error) { + var result SellingPriceAggregate + + db := r.DB().WithContext(ctx). + Table("marketing_delivery_products AS mdp"). + Select("COALESCE(SUM(mdp.total_price), 0) AS total_price, COALESCE(SUM(mdp.total_weight), 0) AS total_weight"). + Joins("JOIN marketing_products AS mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pw.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("mdp.delivery_date IS NOT NULL"). + Where("mdp.delivery_date >= ? AND mdp.delivery_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&result).Error; err != nil { + return SellingPriceAggregate{}, err + } + + return result, nil +} + +func (r *DashboardRepositoryImpl) SumSapronakCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("purchase_items AS pi"). + Select("COALESCE(SUM(pi.total_price), 0) AS total"). + Joins("JOIN products AS p ON p.id = pi.product_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN product_warehouses AS pw ON pw.id = pi.product_warehouse_id"). + Joins("LEFT JOIN project_flock_kandangs AS pfk ON pfk.id = COALESCE(pi.project_flock_kandang_id, pw.project_flock_kandang_id)"). + Joins("LEFT JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("f.name IN ?", []utils.FlagType{utils.FlagDOC, utils.FlagPakan, utils.FlagOVK}). + Where("pi.received_date IS NOT NULL"). + Where("pi.received_date >= ? AND pi.received_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) SumBopCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + return r.sumExpenseRealization(ctx, start, end, filters, func(db *gorm.DB) *gorm.DB { + return db. + Where("e.category = ?", utils.ExpenseCategoryBOP). + Joins("LEFT JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ? AND f.name = ?", entity.FlagableTypeNonstock, utils.FlagEkspedisi). + Where("f.id IS NULL") + }) +} + +func (r *DashboardRepositoryImpl) SumEkspedisiCost(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) { + return r.sumExpenseRealization(ctx, start, end, filters, func(db *gorm.DB) *gorm.DB { + return db. + Joins("JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ?", entity.FlagableTypeNonstock). + Where("f.name = ?", utils.FlagEkspedisi) + }) +} + +func (r *DashboardRepositoryImpl) sumExpenseRealization(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, modifier func(*gorm.DB) *gorm.DB) (float64, error) { + var total float64 + + db := r.DB().WithContext(ctx). + Table("expense_realizations AS er"). + Select("COALESCE(SUM(er.qty * er.price), 0) AS total"). + Joins("JOIN expense_nonstocks AS en ON en.id = er.expense_nonstock_id"). + Joins("JOIN expenses AS e ON e.id = en.expense_id"). + Joins("LEFT JOIN project_flock_kandangs AS pfk ON pfk.id = en.project_flock_kandang_id"). + Joins("LEFT JOIN kandangs AS k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)"). + Where("e.realization_date >= ? AND e.realization_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + if modifier != nil { + db = modifier(db) + } + + if err := db.Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + +func (r *DashboardRepositoryImpl) standardIDSubquery(filters *validation.DashboardFilter) *gorm.DB { + db := r.DB(). + Table("project_flocks AS pf"). + Select("DISTINCT pf.production_standard_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pf.production_standard_id > 0") + + if filters != nil { + if len(filters.FlockIds) > 0 { + db = db.Where("pf.id IN ?", filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + db = db.Where("k.id IN ?", filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + db = db.Where("k.location_id IN ?", filters.LokasiIds) + } + } + + return db +} + +func (r *DashboardRepositoryImpl) standardSourceSubquery(filters *validation.DashboardFilter) *gorm.DB { + db := r.DB(). + Table("project_flocks AS pf"). + Select("DISTINCT pf.production_standard_id, pf.fcr_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pf.production_standard_id > 0"). + Where("pf.fcr_id > 0") + + if filters != nil { + if len(filters.FlockIds) > 0 { + db = db.Where("pf.id IN ?", filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + db = db.Where("k.id IN ?", filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + db = db.Where("k.location_id IN ?", filters.LokasiIds) + } + } + + return db +} + +func (r *DashboardRepositoryImpl) GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) { + seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) + if err != nil { + return nil, err + } + + var rows []ComparisonSeries + db := r.DB().WithContext(ctx). + Table("recordings AS r"). + Select(fmt.Sprintf("%s AS id, %s AS label", seriesExpr, labelExpr)). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + db = applyDashboardFilters(db, filters) + + if err := db.Group(groupExpr).Order(orderExpr).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType, metric string) ([]ComparisonWeeklyMetric, error) { + seriesExpr, _, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) + if err != nil { + return nil, err + } + + metricExpr, err := comparisonMetricColumn(metric) + if err != nil { + return nil, err + } + + var rows []ComparisonWeeklyMetric + db := r.DB().WithContext(ctx). + Table("recordings AS r"). + Select(fmt.Sprintf(`((r.day - 1) / 7 + 1) AS week, + %s AS series_id, + COALESCE(AVG(%s), 0) AS value`, seriesExpr, metricExpr)). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + groupBy := fmt.Sprintf("week, %s", groupExpr) + orderBy := fmt.Sprintf("week ASC, %s", orderExpr) + if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonUniformityMetric, error) { + seriesExpr, _, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) + if err != nil { + return nil, err + } + + var rows []ComparisonUniformityMetric + db := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity AS u"). + Select(fmt.Sprintf(`u.week AS week, + %s AS series_id, + COALESCE(AVG(u.uniformity), 0) AS uniformity, + COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, seriesExpr)). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("u.uniform_date IS NOT NULL"). + Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end) + + db = applyDashboardFilters(db, filters) + + groupBy := fmt.Sprintf("u.week, %s", groupExpr) + orderBy := fmt.Sprintf("u.week ASC, %s", orderExpr) + if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) { + var rows []EggQualityWeeklyMetric + + db := r.DB().WithContext(ctx). + Table("recording_eggs AS re"). + Select(` + ((r.day - 1) / 7 + 1) AS week, + COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty, + COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty, + COALESCE(SUM(re.qty), 0) AS total_qty`, + utils.FlagTelurUtuh, + utils.FlagTelurPutih, + utils.FlagTelurRetak, + utils.FlagTelurPecah, + ). + Joins("JOIN recordings AS r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.name IN ?", []utils.FlagType{utils.FlagTelurUtuh, utils.FlagTelurPutih, utils.FlagTelurRetak, utils.FlagTelurPecah}). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) { + var rows []WeeklyEggWeightMetric + + db := r.DB().WithContext(ctx). + Table("recording_eggs AS re"). + Select(` + ((r.day - 1) / 7 + 1) AS week, + COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`). + Joins("JOIN recordings AS r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) { + var rows []WeeklyFeedUsageMetric + + db := r.DB().WithContext(ctx). + Table("recording_stocks AS rs"). + Select(` + ((r.day - 1) / 7 + 1) AS week, + COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, + LOWER(uoms.name) AS uom_name`). + Joins("JOIN recordings AS r ON r.id = rs.recording_id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN uoms ON uoms.id = p.uom_id"). + Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ? AND UPPER(f.name) = ?", entity.FlagableTypeProduct, "PAKAN"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("r.day IS NOT NULL AND r.day > 0") + + db = applyDashboardFilters(db, filters) + + if err := db.Group("week, LOWER(uoms.name)").Order("week ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func comparisonSeriesColumns(comparisonType string) (string, string, string, string, error) { + switch strings.ToUpper(strings.TrimSpace(comparisonType)) { + case validation.ComparisonTypeFarm: + return "loc.id", "loc.name", "loc.id, loc.name", "loc.name", nil + case validation.ComparisonTypeFlock: + return "pf.id", "pf.flock_name", "pf.id, pf.flock_name", "pf.flock_name", nil + case validation.ComparisonTypeKandang: + return "k.id", "k.name", "k.id, k.name", "k.name", nil + default: + return "", "", "", "", fmt.Errorf("invalid comparison_type") + } +} + +func comparisonMetricColumn(metric string) (string, error) { + switch strings.ToLower(strings.TrimSpace(metric)) { + case validation.MetricFcr: + return "r.fcr_value", nil + case validation.MetricMortality: + return "r.cum_depletion_rate", nil + case validation.MetricLaying: + return "r.hen_day", nil + case validation.MetricEggWeight: + return "r.egg_weight", nil + case validation.MetricFeedIntake: + return "r.feed_intake", nil + default: + return "", fmt.Errorf("invalid metric") + } +} diff --git a/internal/modules/dashboards/route.go b/internal/modules/dashboards/route.go new file mode 100644 index 00000000..e4df0a4d --- /dev/null +++ b/internal/modules/dashboards/route.go @@ -0,0 +1,26 @@ +package dashboards + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/controllers" + dashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func DashboardRoutes(v1 fiber.Router, u user.UserService, s dashboard.DashboardService) { + ctrl := controller.NewDashboardController(s) + + route := v1.Group("/dashboards") + route.Use(m.Auth(u)) + + // route.Get("/", m.RequirePermissions(m.P_FinanceGetAll), ctrl.GetAll) + // route.Post("/", m.RequirePermissions(m.P_FinanceGetOne), ctrl.CreateOne) + // route.Get("/:id", m.RequirePermissions(m.P_FinanceCreateOne), ctrl.GetOne) + // route.Patch("/:id", m.RequirePermissions(m.P_FinanceUpdateOne), ctrl.UpdateOne) + // route.Delete("/:id", m.RequirePermissions(m.P_FinanceDeleteOne), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + // route.Get("/:id", ctrl.GetOne) +} diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go new file mode 100644 index 00000000..6653d669 --- /dev/null +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -0,0 +1,867 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/sirupsen/logrus" +) + +type DashboardService interface { + GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) +} + +type dashboardService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.DashboardRepository +} + +func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate) DashboardService { + return &dashboardService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return dto.DashboardPerformanceOverviewDTO{}, 0, err + } + + filter := &validation.DashboardFilter{ + LokasiIds: params.LokasiIds, + FlockIds: params.FlockIds, + KandangIds: params.KandangIds, + } + + statistics, err := s.buildPerformanceStatistics(ctx, params, filter) + if err != nil { + return dto.DashboardPerformanceOverviewDTO{}, 0, err + } + + charts, err := s.buildPerformanceCharts(ctx, params, filter) + if err != nil { + return dto.DashboardPerformanceOverviewDTO{}, 0, err + } + + response := dto.DashboardPerformanceOverviewDTO{ + StatisticsData: statistics, + Charts: charts, + } + + if len(params.Include) > 0 { + include := map[string]bool{} + for _, item := range params.Include { + include[item] = true + } + if !include["statistics"] { + response.StatisticsData = []dto.DashboardStatisticsDTO{} + } + if !include["charts"] { + response.Charts = map[string]dto.DashboardChartDTO{} + } + } + if response.StatisticsData == nil { + response.StatisticsData = []dto.DashboardStatisticsDTO{} + } + if response.Charts == nil { + response.Charts = map[string]dto.DashboardChartDTO{} + } + + return response, 1, nil +} + +func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) ([]dto.DashboardStatisticsDTO, error) { + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, fmt.Errorf("failed to load timezone configuration: %w", err) + } + + if params.PeriodStart.IsZero() || params.PeriodEnd.IsZero() || params.PeriodEndExclusive.IsZero() { + return nil, errors.New("period dates are not initialized") + } + startDate := params.PeriodStart + endDate := params.PeriodEnd + endExclusive := params.PeriodEndExclusive + + hppCurrent, hppLast, err := s.calculateHppGlobal(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + + sellingCurrent, sellingLast, err := s.calculateSellingPrice(ctx, filter, endDate, location) + if err != nil { + return nil, err + } + + fcrCurrent, fcrLast, err := s.calculateFcr(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + + mortalityCurrent, mortalityLast, err := s.calculateMortality(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + + hppPercent := 0.0 + if hppLast > 0 { + hppPercent = (hppCurrent - hppLast) / hppLast * 100 + } + + sellingPercent := 0.0 + if sellingLast > 0 { + sellingPercent = sellingCurrent / sellingLast * 100 + } + + fcrPercent := 0.0 + if fcrLast > 0 { + fcrPercent = (fcrCurrent - fcrLast) / fcrLast * 100 + } + + mortalityPercent := 0.0 + if mortalityLast > 0 { + mortalityPercent = (mortalityCurrent - mortalityLast) / mortalityLast * 100 + } + + return []dto.DashboardStatisticsDTO{ + { + Label: "HPP Global", + Value: roundTo(hppCurrent, 0), + PercentLastMonth: hppPercent, + }, + { + Label: "Avg. Selling Price", + Value: roundTo(sellingCurrent, 0), + PercentLastMonth: sellingPercent, + }, + { + Label: "FCR", + Value: roundTo(fcrCurrent, 2), + PercentLastMonth: fcrPercent, + }, + { + Label: "Mortality", + Value: roundTo(mortalityCurrent, 2), + PercentLastMonth: mortalityPercent, + }, + }, nil +} + +func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { + if params.AnalysisMode == validation.AnalysisModeComparison { + return s.buildComparisonCharts(ctx, params, filter) + } + + if params.PeriodStart.IsZero() || params.PeriodEndExclusive.IsZero() { + return nil, errors.New("period dates are not initialized") + } + + startDate := params.PeriodStart + endExclusive := params.PeriodEndExclusive + + recordings, err := s.Repository.GetRecordingWeeklyMetrics(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + uniformities, err := s.Repository.GetUniformityWeeklyMetrics(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + weekSet := map[int]struct{}{} + for _, row := range recordings { + if row.Week > 0 { + weekSet[row.Week] = struct{}{} + } + } + for _, row := range uniformities { + if row.Week > 0 { + weekSet[row.Week] = struct{}{} + } + } + + weeks := make([]int, 0, len(weekSet)) + for week := range weekSet { + weeks = append(weeks, week) + } + sort.Ints(weeks) + + standards, err := s.Repository.GetStandardWeeklyMetrics(ctx, weeks, filter) + if err != nil { + return nil, err + } + + standardFcr, err := s.Repository.GetStandardFcrWeekly(ctx, weeks, filter) + if err != nil { + return nil, err + } + + recordingMap := map[int]repository.RecordingWeeklyMetric{} + for _, row := range recordings { + recordingMap[row.Week] = row + } + + uniformityMap := map[int]repository.UniformityWeeklyMetric{} + for _, row := range uniformities { + uniformityMap[row.Week] = row + } + + standardMap := map[int]repository.StandardWeeklyMetric{} + for _, row := range standards { + standardMap[row.Week] = row + } + + standardFcrMap := map[int]float64{} + for _, row := range standardFcr { + standardFcrMap[row.Week] = row.StdFcr + } + + weeklyEggs, err := s.Repository.GetEggWeightWeeklyGrams(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + weeklyFeedRows, err := s.Repository.GetFeedUsageWeeklyByUom(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + weeklyEggMap := map[int]float64{} + for _, row := range weeklyEggs { + weeklyEggMap[row.Week] = row.EggWeightGrams + } + + weeklyFeedMap := map[int]float64{} + for _, row := range weeklyFeedRows { + weeklyFeedMap[row.Week] += feedUsageRowToGrams(row.TotalQty, row.UomName) + } + + bodyWeightDataset := make([]map[string]interface{}, 0, len(weeks)) + performanceDataset := make([]map[string]interface{}, 0, len(weeks)) + fcrDataset := make([]map[string]interface{}, 0, len(weeks)) + deplesiDataset := make([]map[string]interface{}, 0, len(weeks)) + qualityDataset := make([]map[string]interface{}, 0, len(weeks)) + + cumEgg := 0.0 + cumFeed := 0.0 + + for _, week := range weeks { + rec := recordingMap[week] + uni := uniformityMap[week] + std := standardMap[week] + stdFcr := standardFcrMap[week] + weekEgg := weeklyEggMap[week] + weekFeed := weeklyFeedMap[week] + + actFcr := 0.0 + if weekFeed > 0 { + actFcr = weekEgg / weekFeed + } + + cumEgg += weekEgg + cumFeed += weekFeed + actFcrCum := 0.0 + if cumFeed > 0 { + actFcrCum = cumEgg / cumFeed + } + + bodyWeightDataset = append(bodyWeightDataset, map[string]interface{}{ + "week": week, + "body_weight": roundTo(uni.AverageWeight, 2), + "std_body_weight": roundTo(std.StdBodyWeight, 2), + }) + + performanceDataset = append(performanceDataset, map[string]interface{}{ + "week": week, + "act_laying": roundTo(rec.HandDay, 2), + "std_laying": roundTo(std.StdLaying, 2), + "act_egg_weight": roundTo(rec.EggWeight, 2), + "std_egg_weight": roundTo(std.StdEggWeight, 2), + "act_feed_intake": roundTo(rec.FeedIntake, 2), + "std_feed_intake": roundTo(std.StdFeedIntake, 2), + "act_uniformity": roundTo(uni.Uniformity, 2), + "std_uniformity": roundTo(std.StdUniformity, 2), + }) + + fcrDataset = append(fcrDataset, map[string]interface{}{ + "week": week, + "act_fcr": roundTo(actFcr, 2), + "std_fcr": roundTo(stdFcr, 2), + "act_fcr_cum": roundTo(actFcrCum, 2), + "std_fcr_cum": roundTo(stdFcr, 2), + }) + + deplesiDataset = append(deplesiDataset, map[string]interface{}{ + "week": week, + "act_deplesi": roundTo(rec.CumDepletionRate, 2), + "std_deplesi": roundTo(std.StdDepletion, 2), + }) + } + + qualityRows, err := s.Repository.GetEggQualityWeeklyMetrics(ctx, startDate, endExclusive, filter) + if err != nil { + return nil, err + } + + for _, row := range qualityRows { + normalPercent := 0.0 + abnormalPercent := 0.0 + if row.TotalQty > 0 { + normalPercent = (row.NormalQty / row.TotalQty) * 100 + abnormalPercent = (row.AbnormalQty / row.TotalQty) * 100 + } + qualityDataset = append(qualityDataset, map[string]interface{}{ + "week": row.Week, + "normal": roundTo(normalPercent, 2), + "abnormal": roundTo(abnormalPercent, 2), + }) + } + + charts := map[string]dto.DashboardChartDTO{ + "body_weight": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "body_weight", Label: "Body Weight", Unit: "g"}, + {Id: "std_body_weight", Label: "STD. Body Weight", Unit: "g"}, + }, + Dataset: bodyWeightDataset, + }, + "performance": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "act_laying", Label: "Act. % Laying", Unit: "%"}, + {Id: "std_laying", Label: "STD. % Laying", Unit: "%"}, + {Id: "act_egg_weight", Label: "Act. Egg Weight", Unit: "%"}, + {Id: "std_egg_weight", Label: "STD. Egg Weight", Unit: "%"}, + {Id: "act_feed_intake", Label: "Act. Feed Intake", Unit: "%"}, + {Id: "std_feed_intake", Label: "STD. Feed Intake", Unit: "%"}, + {Id: "act_uniformity", Label: "Act. Uniformity", Unit: "%"}, + {Id: "std_uniformity", Label: "STD. Uniformity", Unit: "%"}, + }, + Dataset: performanceDataset, + }, + "fcr": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "act_fcr", Label: "Act. FCR", Unit: "%"}, + {Id: "std_fcr", Label: "STD. FCR", Unit: "%"}, + {Id: "act_fcr_cum", Label: "Act. FCR Cummulative", Unit: "%"}, + {Id: "std_fcr_cum", Label: "STD. FCR Cummulative", Unit: "%"}, + }, + Dataset: fcrDataset, + }, + "deplesi": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "act_deplesi", Label: "Act. Deplesi", Unit: "%"}, + {Id: "std_deplesi", Label: "STD. Deplesi", Unit: "%"}, + }, + Dataset: deplesiDataset, + }, + "quality_control": { + Series: []dto.DashboardChartSeriesDTO{ + {Id: "normal", Label: "Normal", Unit: "%"}, + {Id: "abnormal", Label: "Abnormal", Unit: "%"}, + }, + Dataset: qualityDataset, + }, + } + + return charts, nil +} + +func (s dashboardService) buildComparisonCharts(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { + if params.PeriodStart.IsZero() || params.PeriodEndExclusive.IsZero() { + return nil, errors.New("period dates are not initialized") + } + + startDate := params.PeriodStart + endExclusive := params.PeriodEndExclusive + + metric := strings.ToLower(strings.TrimSpace(params.Metric)) + if metric == "" { + return s.buildComparisonChartsAll(ctx, startDate, endExclusive, params, filter) + } + + seriesRows, err := s.Repository.GetComparisonSeries(ctx, startDate, endExclusive, filter, params.ComparisonType) + if err != nil { + return nil, err + } + + metricRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, metric) + if err != nil { + return nil, err + } + + weeks, actualMap := mapComparisonWeeklyMetricRows(metricRows) + if len(weeks) == 0 { + return map[string]dto.DashboardChartDTO{}, nil + } + + standardMap, err := s.standardComparisonMap(ctx, weeks, metric, filter) + if err != nil { + return nil, err + } + + chart := buildComparisonPercentChart(seriesRows, weeks, actualMap, standardMap) + return map[string]dto.DashboardChartDTO{ + strings.ToLower(params.ComparisonType): chart, + }, nil +} + +func (s dashboardService) buildComparisonChartsAll(ctx context.Context, startDate, endExclusive time.Time, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { + seriesRows, err := s.Repository.GetComparisonSeries(ctx, startDate, endExclusive, filter, params.ComparisonType) + if err != nil { + return nil, err + } + + layingRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricLaying) + if err != nil { + return nil, err + } + eggWeightRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricEggWeight) + if err != nil { + return nil, err + } + feedIntakeRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricFeedIntake) + if err != nil { + return nil, err + } + fcrRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricFcr) + if err != nil { + return nil, err + } + deplesiRows, err := s.Repository.GetComparisonWeeklyMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType, validation.MetricMortality) + if err != nil { + return nil, err + } + uniformityRows, err := s.Repository.GetComparisonWeeklyUniformityMetrics(ctx, startDate, endExclusive, filter, params.ComparisonType) + if err != nil { + return nil, err + } + + weeks := mergeComparisonWeeks( + layingRows, + eggWeightRows, + feedIntakeRows, + fcrRows, + deplesiRows, + uniformityRows, + ) + if len(weeks) == 0 { + return map[string]dto.DashboardChartDTO{}, nil + } + + standards, err := s.Repository.GetStandardWeeklyMetrics(ctx, weeks, filter) + if err != nil { + return nil, err + } + standardFcr, err := s.Repository.GetStandardFcrWeekly(ctx, weeks, filter) + if err != nil { + return nil, err + } + + stdBodyWeight := map[int]float64{} + stdLaying := map[int]float64{} + stdEggWeight := map[int]float64{} + stdFeedIntake := map[int]float64{} + stdUniformity := map[int]float64{} + stdDeplesi := map[int]float64{} + for _, row := range standards { + stdBodyWeight[row.Week] = row.StdBodyWeight + stdLaying[row.Week] = row.StdLaying + stdEggWeight[row.Week] = row.StdEggWeight + stdFeedIntake[row.Week] = row.StdFeedIntake + stdUniformity[row.Week] = row.StdUniformity + stdDeplesi[row.Week] = row.StdDepletion + } + + stdFcr := map[int]float64{} + for _, row := range standardFcr { + stdFcr[row.Week] = row.StdFcr + } + + layingWeeks, layingActual := mapComparisonWeeklyMetricRows(layingRows) + eggWeightWeeks, eggWeightActual := mapComparisonWeeklyMetricRows(eggWeightRows) + feedWeeks, feedActual := mapComparisonWeeklyMetricRows(feedIntakeRows) + fcrWeeks, fcrActual := mapComparisonWeeklyMetricRows(fcrRows) + deplesiWeeks, deplesiActual := mapComparisonWeeklyMetricRows(deplesiRows) + bodyWeightWeeks, bodyWeightActual, uniformityWeeks, uniformityActual := mapComparisonUniformityRows(uniformityRows) + + charts := map[string]dto.DashboardChartDTO{} + if len(bodyWeightWeeks) > 0 { + charts["body_weight"] = buildComparisonPercentChart(seriesRows, bodyWeightWeeks, bodyWeightActual, stdBodyWeight) + } + if len(layingWeeks) > 0 { + charts["laying"] = buildComparisonPercentChart(seriesRows, layingWeeks, layingActual, stdLaying) + } + if len(eggWeightWeeks) > 0 { + charts["egg_weight"] = buildComparisonPercentChart(seriesRows, eggWeightWeeks, eggWeightActual, stdEggWeight) + } + if len(feedWeeks) > 0 { + charts["feed_intake"] = buildComparisonPercentChart(seriesRows, feedWeeks, feedActual, stdFeedIntake) + } + if len(uniformityWeeks) > 0 { + charts["uniformity"] = buildComparisonPercentChart(seriesRows, uniformityWeeks, uniformityActual, stdUniformity) + } + if len(fcrWeeks) > 0 { + charts["fcr"] = buildComparisonPercentChart(seriesRows, fcrWeeks, fcrActual, stdFcr) + } + if len(deplesiWeeks) > 0 { + charts["deplesi"] = buildComparisonPercentChart(seriesRows, deplesiWeeks, deplesiActual, stdDeplesi) + } + + return charts, nil +} + +func (s dashboardService) standardComparisonMap(ctx context.Context, weeks []int, metric string, filter *validation.DashboardFilter) (map[int]float64, error) { + switch metric { + case validation.MetricFcr: + rows, err := s.Repository.GetStandardFcrWeekly(ctx, weeks, filter) + if err != nil { + return nil, err + } + result := map[int]float64{} + for _, row := range rows { + result[row.Week] = row.StdFcr + } + return result, nil + case validation.MetricLaying, validation.MetricEggWeight, validation.MetricFeedIntake, validation.MetricMortality: + rows, err := s.Repository.GetStandardWeeklyMetrics(ctx, weeks, filter) + if err != nil { + return nil, err + } + result := map[int]float64{} + for _, row := range rows { + switch metric { + case validation.MetricLaying: + result[row.Week] = row.StdLaying + case validation.MetricEggWeight: + result[row.Week] = row.StdEggWeight + case validation.MetricFeedIntake: + result[row.Week] = row.StdFeedIntake + case validation.MetricMortality: + result[row.Week] = row.StdDepletion + } + } + return result, nil + default: + return map[int]float64{}, nil + } +} + +func mapComparisonWeeklyMetricRows(rows []repository.ComparisonWeeklyMetric) ([]int, map[int]map[uint]float64) { + weekSet := map[int]struct{}{} + values := map[int]map[uint]float64{} + for _, row := range rows { + if row.Week <= 0 { + continue + } + weekSet[row.Week] = struct{}{} + if values[row.Week] == nil { + values[row.Week] = map[uint]float64{} + } + values[row.Week][row.SeriesId] = row.Value + } + + weeks := make([]int, 0, len(weekSet)) + for week := range weekSet { + weeks = append(weeks, week) + } + sort.Ints(weeks) + return weeks, values +} + +func mapComparisonUniformityRows(rows []repository.ComparisonUniformityMetric) ([]int, map[int]map[uint]float64, []int, map[int]map[uint]float64) { + bodyWeightSet := map[int]struct{}{} + bodyWeightValues := map[int]map[uint]float64{} + uniformitySet := map[int]struct{}{} + uniformityValues := map[int]map[uint]float64{} + + for _, row := range rows { + if row.Week <= 0 { + continue + } + bodyWeightSet[row.Week] = struct{}{} + uniformitySet[row.Week] = struct{}{} + if bodyWeightValues[row.Week] == nil { + bodyWeightValues[row.Week] = map[uint]float64{} + } + if uniformityValues[row.Week] == nil { + uniformityValues[row.Week] = map[uint]float64{} + } + bodyWeightValues[row.Week][row.SeriesId] = row.AverageWeight + uniformityValues[row.Week][row.SeriesId] = row.Uniformity + } + + bodyWeightWeeks := make([]int, 0, len(bodyWeightSet)) + for week := range bodyWeightSet { + bodyWeightWeeks = append(bodyWeightWeeks, week) + } + sort.Ints(bodyWeightWeeks) + + uniformityWeeks := make([]int, 0, len(uniformitySet)) + for week := range uniformitySet { + uniformityWeeks = append(uniformityWeeks, week) + } + sort.Ints(uniformityWeeks) + + return bodyWeightWeeks, bodyWeightValues, uniformityWeeks, uniformityValues +} + +func mergeComparisonWeeks(rows ...interface{}) []int { + weekSet := map[int]struct{}{} + for _, row := range rows { + switch typed := row.(type) { + case []repository.ComparisonWeeklyMetric: + for _, item := range typed { + if item.Week > 0 { + weekSet[item.Week] = struct{}{} + } + } + case []repository.ComparisonUniformityMetric: + for _, item := range typed { + if item.Week > 0 { + weekSet[item.Week] = struct{}{} + } + } + } + } + + weeks := make([]int, 0, len(weekSet)) + for week := range weekSet { + weeks = append(weeks, week) + } + sort.Ints(weeks) + return weeks +} + +func buildComparisonPercentChart(seriesRows []repository.ComparisonSeries, weeks []int, actual map[int]map[uint]float64, standard map[int]float64) dto.DashboardChartDTO { + series := make([]dto.DashboardChartSeriesDTO, 0, len(seriesRows)) + for _, row := range seriesRows { + series = append(series, dto.DashboardChartSeriesDTO{ + Id: strconv.FormatUint(uint64(row.Id), 10), + Label: row.Label, + Unit: "%", + }) + } + + dataset := make([]map[string]interface{}, 0, len(weeks)) + for _, week := range weeks { + row := map[string]interface{}{ + "week": week, + } + std := standard[week] + for _, sRow := range seriesRows { + key := strconv.FormatUint(uint64(sRow.Id), 10) + actualVal := actual[week][sRow.Id] + percent := 0.0 + if std > 0 { + percent = (actualVal / std) * 100 + } + row[key] = roundTo(percent, 2) + } + dataset = append(dataset, row) + } + + return dto.DashboardChartDTO{ + Series: series, + Dataset: dataset, + } +} + +func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, 0, err + } + totalCost, err := s.sumHppCost(ctx, filter, startDate, endExclusive) + if err != nil { + return 0, 0, err + } + + hppCurrent := 0.0 + if totalEggKg > 0 { + hppCurrent = totalCost / totalEggKg + } + + lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + lastEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, lastMonthStart, lastMonthEndExclusive, filter) + if err != nil { + return 0, 0, err + } + lastCost, err := s.sumHppCost(ctx, filter, lastMonthStart, lastMonthEndExclusive) + if err != nil { + return 0, 0, err + } + + hppLast := 0.0 + if lastEggKg > 0 { + hppLast = lastCost / lastEggKg + } + + return hppCurrent, hppLast, nil +} + +func (s dashboardService) calculateSellingPrice(ctx context.Context, filter *validation.DashboardFilter, endDate time.Time, location *time.Location) (float64, float64, error) { + startPrevMonth, endPrevMonthExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + currentEndExclusive := endDate.AddDate(0, 0, 1) + + currentAvg, err := s.avgSellingPrice(ctx, filter, startPrevMonth, currentEndExclusive) + if err != nil { + return 0, 0, err + } + + lastAvg, err := s.avgSellingPrice(ctx, filter, startPrevMonth, endPrevMonthExclusive) + if err != nil { + return 0, 0, err + } + + return currentAvg, lastAvg, nil +} + +func (s dashboardService) calculateFcr(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + current, err := s.fcrValue(ctx, filter, startDate, endExclusive) + if err != nil { + return 0, 0, err + } + + lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + last, err := s.fcrValue(ctx, filter, lastMonthStart, lastMonthEndExclusive) + if err != nil { + return 0, 0, err + } + + return current, last, nil +} + +func (s dashboardService) calculateMortality(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + current, err := s.mortalityValue(ctx, filter, startDate, endExclusive) + if err != nil { + return 0, 0, err + } + + lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) + last, err := s.mortalityValue(ctx, filter, lastMonthStart, lastMonthEndExclusive) + if err != nil { + return 0, 0, err + } + + return current, last, nil +} + +func (s dashboardService) fcrValue(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + eggWeightGrams, err := s.Repository.SumEggProductionWeightGrams(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + feedRows, err := s.Repository.GetFeedUsageByUom(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + feedUsageGrams := feedUsageToGrams(feedRows) + + if feedUsageGrams <= 0 { + return 0, nil + } + + return eggWeightGrams / feedUsageGrams, nil +} + +func (s dashboardService) mortalityValue(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + depletions, err := s.Repository.SumDepletions(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + initialPopulation, err := s.Repository.SumInitialPopulation(ctx, endExclusive.AddDate(0, 0, -1), filter) + if err != nil { + return 0, err + } + + if initialPopulation <= 0 { + return 0, nil + } + + return (depletions / initialPopulation) * 100, nil +} + +func (s dashboardService) sumHppCost(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + sapronak, err := s.Repository.SumSapronakCost(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + bop, err := s.Repository.SumBopCost(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + ekspedisi, err := s.Repository.SumEkspedisiCost(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + return sapronak + bop + ekspedisi, nil +} + +func (s dashboardService) avgSellingPrice(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { + result, err := s.Repository.SumSellingPrice(ctx, startDate, endExclusive, filter) + if err != nil { + return 0, err + } + + if result.TotalWeight <= 0 { + return 0, nil + } + + return result.TotalPrice / result.TotalWeight, nil +} + +func feedUsageToGrams(rows []repository.FeedUsageByUom) float64 { + total := 0.0 + for _, row := range rows { + total += feedUsageRowToGrams(row.TotalQty, row.UomName) + } + return total +} + +func feedUsageRowToGrams(totalQty float64, uomName string) float64 { + if totalQty <= 0 { + return 0 + } + switch strings.TrimSpace(strings.ToLower(uomName)) { + case "kilogram", "kg", "kilograms", "kilo": + return totalQty * 1000 + case "gram", "g", "grams": + return totalQty + default: + return totalQty + } +} + +func roundTo(value float64, decimals int) float64 { + if decimals <= 0 { + return math.Round(value) + } + multiplier := math.Pow(10, float64(decimals)) + return math.Round(value*multiplier) / multiplier +} + +func monthRange(t time.Time, location *time.Location) (time.Time, time.Time) { + start := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, location) + endExclusive := start.AddDate(0, 1, 0) + return start, endExclusive +} diff --git a/internal/modules/dashboards/validations/dashboard.validation.go b/internal/modules/dashboards/validations/dashboard.validation.go new file mode 100644 index 00000000..7e582a4f --- /dev/null +++ b/internal/modules/dashboards/validations/dashboard.validation.go @@ -0,0 +1,54 @@ +package validation + +import "time" + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +const ( + AnalysisModeOverview = "OVERVIEW" + AnalysisModeComparison = "COMPARASION" + + ComparisonTypeFarm = "FARM" + ComparisonTypeFlock = "FLOCK" + ComparisonTypeKandang = "KANDANG" + + MetricFcr = "fcr" + MetricMortality = "mortality" + MetricLaying = "laying" + MetricEggWeight = "egg_weight" + MetricFeedIntake = "feed_intake" +) + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + PerformanceOverviewFilter + PeriodStart time.Time `json:"-" query:"-"` + PeriodEnd time.Time `json:"-" query:"-"` + PeriodEndExclusive time.Time `json:"-" query:"-"` +} + +type PerformanceOverviewFilter struct { + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + AnalysisMode string `query:"analysis_mode" validate:"omitempty,oneof=OVERVIEW COMPARASION"` + ComparisonType string `query:"comparison_type" validate:"omitempty,oneof=FARM FLOCK KANDANG"` + Metric string `query:"metric" validate:"omitempty,oneof=fcr mortality laying egg_weight feed_intake"` + LokasiIds []uint `query:"lokasi_ids" validate:"omitempty,dive,gt=0"` + FlockIds []uint `query:"flock_ids" validate:"omitempty,dive,gt=0"` + KandangIds []uint `query:"kandang_ids" validate:"omitempty,dive,gt=0"` + Include []string `query:"include" validate:"omitempty,dive,oneof=statistics charts"` +} + +type DashboardFilter struct { + LokasiIds []uint + FlockIds []uint + KandangIds []uint +} diff --git a/internal/response/response.go b/internal/response/response.go index 710d320e..a6bc087f 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -29,6 +29,14 @@ type SuccessWithPaginate[T any] struct { Data []T `json:"data"` } +type SuccessWithMeta struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta Meta `json:"meta"` + Data interface{} `json:"data"` +} + type ErrorDetails struct { Code int `json:"code"` Status string `json:"status"` diff --git a/internal/route/route.go b/internal/route/route.go index 519ea5aa..71682d2b 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -22,6 +22,7 @@ import ( repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" + dashboards "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards" // MODULE IMPORTS ) @@ -48,6 +49,7 @@ func Routes(app *fiber.App, db *gorm.DB) { repports.RepportModule{}, finance.FinanceModule{}, dailyChecklists.DailyChecklistModule{}, + dashboards.DashboardModule{}, // MODULE REGISTRY } From a54129866e1f680fb9232d87f804452a4264edc5 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 18:35:23 +0700 Subject: [PATCH 2/8] feat(BE-390): adjustment calculate dashboard --- .../dashboards/repositories/dashboard_stats.repository.go | 4 ++-- internal/modules/dashboards/services/dashboard.service.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index a06fdb71..948c1b56 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -24,7 +24,7 @@ type FeedUsageByUom struct { type RecordingWeeklyMetric struct { Week int - HandDay float64 + HenDay float64 EggWeight float64 FeedIntake float64 FcrValue float64 @@ -110,7 +110,7 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, db := r.DB().WithContext(ctx). Table("recordings AS r"). Select(`((r.day - 1) / 7 + 1) AS week, - COALESCE(AVG(r.hen_day), 0) AS hand_day, + COALESCE(AVG(r.hen_day), 0) AS hen_day, COALESCE(AVG(r.egg_weight), 0) AS egg_weight, COALESCE(AVG(r.feed_intake), 0) AS feed_intake, COALESCE(AVG(r.fcr_value), 0) AS fcr_value, diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 6653d669..614307d3 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -288,7 +288,7 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va performanceDataset = append(performanceDataset, map[string]interface{}{ "week": week, - "act_laying": roundTo(rec.HandDay, 2), + "act_laying": roundTo(rec.HenDay, 2), "std_laying": roundTo(std.StdLaying, 2), "act_egg_weight": roundTo(rec.EggWeight, 2), "std_egg_weight": roundTo(std.StdEggWeight, 2), From dc7dc0ba475e5d2e802fc74189f07aaec9f8b8e5 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 18:54:05 +0700 Subject: [PATCH 3/8] adjustment meta --- .../controllers/dashboard.controller.go | 37 ++++++--- .../modules/dashboards/dto/dashboard.dto.go | 2 +- .../dashboards/services/dashboard.service.go | 75 +++++++++++-------- .../validations/dashboard.validation.go | 2 +- 4 files changed, 72 insertions(+), 44 deletions(-) diff --git a/internal/modules/dashboards/controllers/dashboard.controller.go b/internal/modules/dashboards/controllers/dashboard.controller.go index 47e8d5bc..bebad10f 100644 --- a/internal/modules/dashboards/controllers/dashboard.controller.go +++ b/internal/modules/dashboards/controllers/dashboard.controller.go @@ -61,9 +61,9 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error { return ids, nil } - lokasiIds, err := parseUintListParam(c.Query("lokasi_ids", "")) + lokasiIds, err := parseUintListParam(c.Query("location_ids", "")) if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid lokasi_ids") + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_ids") } flockIds, err := parseUintListParam(c.Query("flock_ids", "")) @@ -128,16 +128,29 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error { return err } - filters := dto.DashboardFiltersDTO{ - StartDate: query.StartDate, - EndDate: query.EndDate, - AnalysisMode: query.AnalysisMode, - ComparisonType: query.ComparisonType, - Metric: query.Metric, - LokasiIds: defaultUintSlice(query.LokasiIds), - FlockIds: defaultUintSlice(query.FlockIds), - KandangIds: defaultUintSlice(query.KandangIds), - Include: query.Include, + hasFilter := query.StartDate != "" || + query.EndDate != "" || + len(query.LokasiIds) > 0 || + len(query.FlockIds) > 0 || + len(query.KandangIds) > 0 || + len(query.Include) > 0 || + query.ComparisonType != "" || + query.Metric != "" || + query.AnalysisMode != validation.AnalysisModeOverview + + var filters interface{} + if hasFilter { + filters = dto.DashboardFiltersDTO{ + StartDate: query.StartDate, + EndDate: query.EndDate, + AnalysisMode: query.AnalysisMode, + ComparisonType: query.ComparisonType, + Metric: query.Metric, + LokasiIds: defaultUintSlice(query.LokasiIds), + FlockIds: defaultUintSlice(query.FlockIds), + KandangIds: defaultUintSlice(query.KandangIds), + Include: query.Include, + } } return c.Status(fiber.StatusOK). diff --git a/internal/modules/dashboards/dto/dashboard.dto.go b/internal/modules/dashboards/dto/dashboard.dto.go index bf7d0f91..affa02a6 100644 --- a/internal/modules/dashboards/dto/dashboard.dto.go +++ b/internal/modules/dashboards/dto/dashboard.dto.go @@ -27,7 +27,7 @@ type DashboardFiltersDTO struct { AnalysisMode string `json:"analysis_mode"` ComparisonType string `json:"comparison_type,omitempty"` Metric string `json:"metric,omitempty"` - LokasiIds []uint `json:"lokasi_ids"` + LokasiIds []uint `json:"location_ids"` FlockIds []uint `json:"flock_ids"` KandangIds []uint `json:"kandang_ids"` Include []string `json:"include,omitempty"` diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 614307d3..7c083c98 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -108,14 +108,20 @@ func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params return nil, err } - fcrCurrent, fcrLast, err := s.calculateFcr(ctx, filter, startDate, endExclusive, endDate, location) - if err != nil { - return nil, err - } - - mortalityCurrent, mortalityLast, err := s.calculateMortality(ctx, filter, startDate, endExclusive, endDate, location) - if err != nil { - return nil, err + hasFilter := filter != nil && (len(filter.LokasiIds) > 0 || len(filter.FlockIds) > 0 || len(filter.KandangIds) > 0) + fcrCurrent := 0.0 + fcrLast := 0.0 + mortalityCurrent := 0.0 + mortalityLast := 0.0 + if hasFilter { + fcrCurrent, fcrLast, err = s.calculateFcr(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } + mortalityCurrent, mortalityLast, err = s.calculateMortality(ctx, filter, startDate, endExclusive, endDate, location) + if err != nil { + return nil, err + } } hppPercent := 0.0 @@ -128,17 +134,7 @@ func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params sellingPercent = sellingCurrent / sellingLast * 100 } - fcrPercent := 0.0 - if fcrLast > 0 { - fcrPercent = (fcrCurrent - fcrLast) / fcrLast * 100 - } - - mortalityPercent := 0.0 - if mortalityLast > 0 { - mortalityPercent = (mortalityCurrent - mortalityLast) / mortalityLast * 100 - } - - return []dto.DashboardStatisticsDTO{ + stats := []dto.DashboardStatisticsDTO{ { Label: "HPP Global", Value: roundTo(hppCurrent, 0), @@ -149,17 +145,32 @@ func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params Value: roundTo(sellingCurrent, 0), PercentLastMonth: sellingPercent, }, - { - Label: "FCR", - Value: roundTo(fcrCurrent, 2), - PercentLastMonth: fcrPercent, - }, - { - Label: "Mortality", - Value: roundTo(mortalityCurrent, 2), - PercentLastMonth: mortalityPercent, - }, - }, nil + } + + if hasFilter { + fcrPercent := 0.0 + if fcrLast > 0 { + fcrPercent = (fcrCurrent - fcrLast) / fcrLast * 100 + } + mortalityPercent := 0.0 + if mortalityLast > 0 { + mortalityPercent = (mortalityCurrent - mortalityLast) / mortalityLast * 100 + } + stats = append(stats, + dto.DashboardStatisticsDTO{ + Label: "FCR", + Value: roundTo(fcrCurrent, 2), + PercentLastMonth: fcrPercent, + }, + dto.DashboardStatisticsDTO{ + Label: "Mortality", + Value: roundTo(mortalityCurrent, 2), + PercentLastMonth: mortalityPercent, + }, + ) + } + + return stats, nil } func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *validation.Query, filter *validation.DashboardFilter) (map[string]dto.DashboardChartDTO, error) { @@ -171,6 +182,10 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va return nil, errors.New("period dates are not initialized") } + if filter == nil || (len(filter.LokasiIds) == 0 && len(filter.FlockIds) == 0 && len(filter.KandangIds) == 0) { + return map[string]dto.DashboardChartDTO{}, nil + } + startDate := params.PeriodStart endExclusive := params.PeriodEndExclusive diff --git a/internal/modules/dashboards/validations/dashboard.validation.go b/internal/modules/dashboards/validations/dashboard.validation.go index 7e582a4f..dcd698c7 100644 --- a/internal/modules/dashboards/validations/dashboard.validation.go +++ b/internal/modules/dashboards/validations/dashboard.validation.go @@ -41,7 +41,7 @@ type PerformanceOverviewFilter struct { AnalysisMode string `query:"analysis_mode" validate:"omitempty,oneof=OVERVIEW COMPARASION"` ComparisonType string `query:"comparison_type" validate:"omitempty,oneof=FARM FLOCK KANDANG"` Metric string `query:"metric" validate:"omitempty,oneof=fcr mortality laying egg_weight feed_intake"` - LokasiIds []uint `query:"lokasi_ids" validate:"omitempty,dive,gt=0"` + LokasiIds []uint `query:"location_ids" validate:"omitempty,dive,gt=0"` FlockIds []uint `query:"flock_ids" validate:"omitempty,dive,gt=0"` KandangIds []uint `query:"kandang_ids" validate:"omitempty,dive,gt=0"` Include []string `query:"include" validate:"omitempty,dive,oneof=statistics charts"` From 473f4504ea4252986dd38341a94f37d117f2460b Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 19:09:43 +0700 Subject: [PATCH 4/8] feat(BE-309): changes COMPARASION TO COMPARISON --- .../modules/dashboards/validations/dashboard.validation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/dashboards/validations/dashboard.validation.go b/internal/modules/dashboards/validations/dashboard.validation.go index dcd698c7..b372f493 100644 --- a/internal/modules/dashboards/validations/dashboard.validation.go +++ b/internal/modules/dashboards/validations/dashboard.validation.go @@ -8,7 +8,7 @@ type Create struct { const ( AnalysisModeOverview = "OVERVIEW" - AnalysisModeComparison = "COMPARASION" + AnalysisModeComparison = "COMPARISON" ComparisonTypeFarm = "FARM" ComparisonTypeFlock = "FLOCK" @@ -38,7 +38,7 @@ type Query struct { type PerformanceOverviewFilter struct { StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` - AnalysisMode string `query:"analysis_mode" validate:"omitempty,oneof=OVERVIEW COMPARASION"` + AnalysisMode string `query:"analysis_mode" validate:"omitempty,oneof=OVERVIEW COMPARISON"` ComparisonType string `query:"comparison_type" validate:"omitempty,oneof=FARM FLOCK KANDANG"` Metric string `query:"metric" validate:"omitempty,oneof=fcr mortality laying egg_weight feed_intake"` LokasiIds []uint `query:"location_ids" validate:"omitempty,dive,gt=0"` From 167d18fe87720bc7bbec9fae0bad3f9156b9c5cb Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 19:26:25 +0700 Subject: [PATCH 5/8] feat(BE-309): add permission dashboard --- internal/middleware/permissions.go | 3 +++ internal/modules/dashboards/route.go | 10 +--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index e9148927..d04c8ac6 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -1,5 +1,8 @@ package middleware +const( + P_DashboardGetAll = "lti.dashboard.list" +) // project-flock const ( P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" diff --git a/internal/modules/dashboards/route.go b/internal/modules/dashboards/route.go index e4df0a4d..34f2d00b 100644 --- a/internal/modules/dashboards/route.go +++ b/internal/modules/dashboards/route.go @@ -14,13 +14,5 @@ func DashboardRoutes(v1 fiber.Router, u user.UserService, s dashboard.DashboardS route := v1.Group("/dashboards") route.Use(m.Auth(u)) - - // route.Get("/", m.RequirePermissions(m.P_FinanceGetAll), ctrl.GetAll) - // route.Post("/", m.RequirePermissions(m.P_FinanceGetOne), ctrl.CreateOne) - // route.Get("/:id", m.RequirePermissions(m.P_FinanceCreateOne), ctrl.GetOne) - // route.Patch("/:id", m.RequirePermissions(m.P_FinanceUpdateOne), ctrl.UpdateOne) - // route.Delete("/:id", m.RequirePermissions(m.P_FinanceDeleteOne), ctrl.DeleteOne) - - route.Get("/", ctrl.GetAll) - // route.Get("/:id", ctrl.GetOne) + route.Get("/",m.RequirePermissions(m.P_DashboardGetAll) ,ctrl.GetAll) } From 1217f34dcd354a9923c8f8a9fbdfb5fecf3583f8 Mon Sep 17 00:00:00 2001 From: ragilap Date: Sun, 11 Jan 2026 22:19:20 +0700 Subject: [PATCH 6/8] feat(BE):change standart egg in fcr master data --- .../dashboard_stats.repository.go | 113 +++++++--- .../dashboards/services/dashboard.service.go | 199 +++++++++++++++--- 2 files changed, 254 insertions(+), 58 deletions(-) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 948c1b56..7582680b 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -188,39 +188,92 @@ func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, week return nil, nil } - source := r.standardSourceSubquery(filters) - if source == nil { - return nil, nil + filterClause := "" + filterArgs := make([]interface{}, 0) + if filters != nil { + if len(filters.FlockIds) > 0 { + filterClause += " AND pf.id IN ?" + filterArgs = append(filterArgs, filters.FlockIds) + } + if len(filters.KandangIds) > 0 { + filterClause += " AND k.id IN ?" + filterArgs = append(filterArgs, filters.KandangIds) + } + if len(filters.LokasiIds) > 0 { + filterClause += " AND k.location_id IN ?" + filterArgs = append(filterArgs, filters.LokasiIds) + } } - var rows []StandardWeeklyFcrMetric - db := r.DB().WithContext(ctx). - Table("standard_growth_details AS sgd"). - Select(` - sgd.week AS week, - COALESCE(AVG( - COALESCE( - ( - SELECT fs.fcr_number - FROM fcr_standards fs - WHERE fs.fcr_id = src.fcr_id - AND fs.weight >= CASE WHEN sgd.target_mean_bw > 10 THEN sgd.target_mean_bw / 1000 ELSE sgd.target_mean_bw END - ORDER BY fs.weight ASC - LIMIT 1 - ), - ( - SELECT fs.fcr_number - FROM fcr_standards fs - WHERE fs.fcr_id = src.fcr_id - ORDER BY fs.weight DESC - LIMIT 1 - ) - ) - ), 0) AS std_fcr`). - Joins("JOIN (?) AS src ON src.production_standard_id = sgd.production_standard_id", source). - Where("sgd.week IN ?", weeks) + query := fmt.Sprintf(` +WITH src AS ( + SELECT DISTINCT pf.production_standard_id, pf.fcr_id + FROM project_flocks pf + JOIN project_flock_kandangs pfk ON pfk.project_flock_id = pf.id + JOIN kandangs k ON k.id = pfk.kandang_id + WHERE pf.production_standard_id > 0 AND pf.fcr_id > 0 + %s +), +actual AS ( + SELECT u.week AS week, + pf.fcr_id AS fcr_id, + AVG((u.chart_data->'statistics'->>'average_weight')::numeric) AS avg_weight + FROM project_flock_kandang_uniformity u + JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id + JOIN project_flocks pf ON pf.id = pfk.project_flock_id + JOIN kandangs k ON k.id = pfk.kandang_id + WHERE u.week IN ? AND u.uniform_date IS NOT NULL AND pf.fcr_id > 0 + %s + GROUP BY u.week, pf.fcr_id +), +target AS ( + SELECT sgd.week AS week, + src.fcr_id AS fcr_id, + AVG(sgd.target_mean_bw) AS target_mean_bw + FROM standard_growth_details sgd + JOIN src ON src.production_standard_id = sgd.production_standard_id + WHERE sgd.week IN ? + GROUP BY sgd.week, src.fcr_id +), +weights AS ( + SELECT COALESCE(a.week, t.week) AS week, + COALESCE(a.fcr_id, t.fcr_id) AS fcr_id, + COALESCE( + CASE WHEN a.avg_weight > 10 THEN a.avg_weight / 1000 ELSE a.avg_weight END, + CASE WHEN t.target_mean_bw > 10 THEN t.target_mean_bw / 1000 ELSE t.target_mean_bw END + ) AS weight + FROM actual a + FULL OUTER JOIN target t ON t.week = a.week AND t.fcr_id = a.fcr_id +) +SELECT w.week AS week, + COALESCE(AVG( + COALESCE( + (SELECT fs.fcr_number + FROM fcr_standards fs + WHERE fs.fcr_id = w.fcr_id + AND fs.weight >= w.weight + ORDER BY fs.weight ASC + LIMIT 1), + (SELECT fs.fcr_number + FROM fcr_standards fs + WHERE fs.fcr_id = w.fcr_id + ORDER BY fs.weight DESC + LIMIT 1) + ) + ), 0) AS std_fcr +FROM weights w +GROUP BY w.week +ORDER BY w.week ASC +`, filterClause, filterClause) - if err := db.Group("sgd.week").Order("sgd.week ASC").Scan(&rows).Error; err != nil { + args := make([]interface{}, 0, len(filterArgs)*2+2) + args = append(args, filterArgs...) + args = append(args, weeks) + args = append(args, filterArgs...) + args = append(args, weeks) + + var rows []StandardWeeklyFcrMetric + if err := r.DB().WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { return nil, err } diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 7c083c98..f8d532f0 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -507,37 +507,180 @@ func (s dashboardService) buildComparisonChartsAll(ctx context.Context, startDat stdFcr[row.Week] = row.StdFcr } - layingWeeks, layingActual := mapComparisonWeeklyMetricRows(layingRows) - eggWeightWeeks, eggWeightActual := mapComparisonWeeklyMetricRows(eggWeightRows) - feedWeeks, feedActual := mapComparisonWeeklyMetricRows(feedIntakeRows) - fcrWeeks, fcrActual := mapComparisonWeeklyMetricRows(fcrRows) - deplesiWeeks, deplesiActual := mapComparisonWeeklyMetricRows(deplesiRows) - bodyWeightWeeks, bodyWeightActual, uniformityWeeks, uniformityActual := mapComparisonUniformityRows(uniformityRows) + _, layingActual := mapComparisonWeeklyMetricRows(layingRows) + _, eggWeightActual := mapComparisonWeeklyMetricRows(eggWeightRows) + _, feedActual := mapComparisonWeeklyMetricRows(feedIntakeRows) + _, fcrActual := mapComparisonWeeklyMetricRows(fcrRows) + _, deplesiActual := mapComparisonWeeklyMetricRows(deplesiRows) + _, bodyWeightActual, _, uniformityActual := mapComparisonUniformityRows(uniformityRows) - charts := map[string]dto.DashboardChartDTO{} - if len(bodyWeightWeeks) > 0 { - charts["body_weight"] = buildComparisonPercentChart(seriesRows, bodyWeightWeeks, bodyWeightActual, stdBodyWeight) - } - if len(layingWeeks) > 0 { - charts["laying"] = buildComparisonPercentChart(seriesRows, layingWeeks, layingActual, stdLaying) - } - if len(eggWeightWeeks) > 0 { - charts["egg_weight"] = buildComparisonPercentChart(seriesRows, eggWeightWeeks, eggWeightActual, stdEggWeight) - } - if len(feedWeeks) > 0 { - charts["feed_intake"] = buildComparisonPercentChart(seriesRows, feedWeeks, feedActual, stdFeedIntake) - } - if len(uniformityWeeks) > 0 { - charts["uniformity"] = buildComparisonPercentChart(seriesRows, uniformityWeeks, uniformityActual, stdUniformity) - } - if len(fcrWeeks) > 0 { - charts["fcr"] = buildComparisonPercentChart(seriesRows, fcrWeeks, fcrActual, stdFcr) - } - if len(deplesiWeeks) > 0 { - charts["deplesi"] = buildComparisonPercentChart(seriesRows, deplesiWeeks, deplesiActual, stdDeplesi) + aggregateActual := buildAggregateComparisonPercent(weeks, seriesRows, aggregateComparisonInput{ + BodyWeightActual: bodyWeightActual, + LayingActual: layingActual, + EggWeightActual: eggWeightActual, + FeedIntakeActual: feedActual, + UniformityActual: uniformityActual, + FcrActual: fcrActual, + DeplesiActual: deplesiActual, + StdBodyWeight: stdBodyWeight, + StdLaying: stdLaying, + StdEggWeight: stdEggWeight, + StdFeedIntake: stdFeedIntake, + StdUniformity: stdUniformity, + StdFcr: stdFcr, + StdDeplesi: stdDeplesi, + }) + + if len(aggregateActual) == 0 { + return map[string]dto.DashboardChartDTO{}, nil } - return charts, nil + chartKey := strings.ToLower(params.ComparisonType) + return map[string]dto.DashboardChartDTO{ + chartKey: buildComparisonAggregateChart(seriesRows, weeks, aggregateActual), + }, nil +} + +type aggregateComparisonInput struct { + BodyWeightActual map[int]map[uint]float64 + LayingActual map[int]map[uint]float64 + EggWeightActual map[int]map[uint]float64 + FeedIntakeActual map[int]map[uint]float64 + UniformityActual map[int]map[uint]float64 + FcrActual map[int]map[uint]float64 + DeplesiActual map[int]map[uint]float64 + StdBodyWeight map[int]float64 + StdLaying map[int]float64 + StdEggWeight map[int]float64 + StdFeedIntake map[int]float64 + StdUniformity map[int]float64 + StdFcr map[int]float64 + StdDeplesi map[int]float64 +} + +func buildAggregateComparisonPercent(weeks []int, seriesRows []repository.ComparisonSeries, input aggregateComparisonInput) map[int]map[uint]float64 { + result := map[int]map[uint]float64{} + + for _, week := range weeks { + stdBodyWeight := input.StdBodyWeight[week] + stdLaying := input.StdLaying[week] + stdEggWeight := input.StdEggWeight[week] + stdFeedIntake := input.StdFeedIntake[week] + stdUniformity := input.StdUniformity[week] + stdFcr := input.StdFcr[week] + stdDeplesi := input.StdDeplesi[week] + + for _, series := range seriesRows { + sum := 0.0 + count := 0.0 + + if percent, ok := higherIsBetterPercent(input.LayingActual, week, series.Id, stdLaying); ok { + sum += percent + count++ + } + if percent, ok := higherIsBetterPercent(input.EggWeightActual, week, series.Id, stdEggWeight); ok { + sum += percent + count++ + } + if percent, ok := higherIsBetterPercent(input.UniformityActual, week, series.Id, stdUniformity); ok { + sum += percent + count++ + } + if percent, ok := lowerIsBetterPercent(input.FcrActual, week, series.Id, stdFcr); ok { + sum += percent + count++ + } + if percent, ok := lowerIsBetterPercent(input.DeplesiActual, week, series.Id, stdDeplesi); ok { + sum += percent + count++ + } + if percent, ok := higherIsBetterPercent(input.BodyWeightActual, week, series.Id, stdBodyWeight); ok { + sum += percent + count++ + } + if percent, ok := lowerIsBetterPercent(input.FeedIntakeActual, week, series.Id, stdFeedIntake); ok { + sum += percent + count++ + } + + if count == 0 { + continue + } + + if result[week] == nil { + result[week] = map[uint]float64{} + } + result[week][series.Id] = sum / count + } + } + + return result +} + +func higherIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId uint, standard float64) (float64, bool) { + if standard <= 0 { + return 0, false + } + val, ok := metricValue(actual, week, seriesId) + if !ok || val <= 0 { + return 0, false + } + return (val / standard) * 100, true +} + +func lowerIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId uint, standard float64) (float64, bool) { + if standard <= 0 { + return 0, false + } + val, ok := metricValue(actual, week, seriesId) + if !ok || val <= 0 { + return 0, false + } + return (standard / val) * 100, true +} + +func metricValue(actual map[int]map[uint]float64, week int, seriesId uint) (float64, bool) { + weekRows, ok := actual[week] + if !ok { + return 0, false + } + val, ok := weekRows[seriesId] + return val, ok +} + +func buildComparisonAggregateChart(seriesRows []repository.ComparisonSeries, weeks []int, actual map[int]map[uint]float64) dto.DashboardChartDTO { + series := make([]dto.DashboardChartSeriesDTO, 0, len(seriesRows)) + for _, sRow := range seriesRows { + series = append(series, dto.DashboardChartSeriesDTO{ + Id: strconv.FormatUint(uint64(sRow.Id), 10), + Label: sRow.Label, + Unit: "%", + }) + } + + dataset := make([]map[string]interface{}, 0, len(weeks)) + for _, week := range weeks { + row := map[string]interface{}{ + "week": week, + } + values, ok := actual[week] + if !ok { + continue + } + for _, sRow := range seriesRows { + if val, exists := values[sRow.Id]; exists { + row[strconv.FormatUint(uint64(sRow.Id), 10)] = roundTo(val, 2) + } + } + if len(row) > 1 { + dataset = append(dataset, row) + } + } + + return dto.DashboardChartDTO{ + Series: series, + Dataset: dataset, + } } func (s dashboardService) standardComparisonMap(ctx context.Context, weeks []int, metric string, filter *validation.DashboardFilter) (map[int]float64, error) { From d1d94357cf255ea3a1319392ad78de939914074e Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 12 Jan 2026 00:27:53 +0700 Subject: [PATCH 7/8] [FIX/BE] add clamp maximum value --- .../dashboards/services/dashboard.service.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index f8d532f0..a60f1555 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -625,7 +625,7 @@ func higherIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId u if !ok || val <= 0 { return 0, false } - return (val / standard) * 100, true + return clampPercent((val / standard) * 100), true } func lowerIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId uint, standard float64) (float64, bool) { @@ -636,7 +636,7 @@ func lowerIsBetterPercent(actual map[int]map[uint]float64, week int, seriesId ui if !ok || val <= 0 { return 0, false } - return (standard / val) * 100, true + return clampPercent((standard / val) * 100), true } func metricValue(actual map[int]map[uint]float64, week int, seriesId uint) (float64, bool) { @@ -648,6 +648,16 @@ func metricValue(actual map[int]map[uint]float64, week int, seriesId uint) (floa return val, ok } +func clampPercent(value float64) float64 { + if value < 0 { + return 0 + } + if value > 200 { + return 200 + } + return value +} + func buildComparisonAggregateChart(seriesRows []repository.ComparisonSeries, weeks []int, actual map[int]map[uint]float64) dto.DashboardChartDTO { series := make([]dto.DashboardChartSeriesDTO, 0, len(seriesRows)) for _, sRow := range seriesRows { From c15ff8a211503c7a1a72dc04af10a607c21f106f Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 12 Jan 2026 01:15:31 +0700 Subject: [PATCH 8/8] [FIX/BE] add percent rasio in statistic dashboard --- .../dashboards/services/dashboard.service.go | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index a60f1555..8fa0a2c9 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -124,48 +124,35 @@ func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params } } - hppPercent := 0.0 - if hppLast > 0 { - hppPercent = (hppCurrent - hppLast) / hppLast * 100 - } - - sellingPercent := 0.0 - if sellingLast > 0 { - sellingPercent = sellingCurrent / sellingLast * 100 - } + hppPercent := percentDelta(hppCurrent, hppLast) + sellingPercent := percentDelta(sellingCurrent, sellingLast) stats := []dto.DashboardStatisticsDTO{ { Label: "HPP Global", Value: roundTo(hppCurrent, 0), - PercentLastMonth: hppPercent, + PercentLastMonth: roundTo(hppPercent*100, 2), }, { Label: "Avg. Selling Price", Value: roundTo(sellingCurrent, 0), - PercentLastMonth: sellingPercent, + PercentLastMonth: roundTo(sellingPercent*100, 2), }, } if hasFilter { - fcrPercent := 0.0 - if fcrLast > 0 { - fcrPercent = (fcrCurrent - fcrLast) / fcrLast * 100 - } - mortalityPercent := 0.0 - if mortalityLast > 0 { - mortalityPercent = (mortalityCurrent - mortalityLast) / mortalityLast * 100 - } + fcrPercent := percentDelta(fcrCurrent, fcrLast) + mortalityPercent := percentDelta(mortalityCurrent, mortalityLast) stats = append(stats, dto.DashboardStatisticsDTO{ Label: "FCR", Value: roundTo(fcrCurrent, 2), - PercentLastMonth: fcrPercent, + PercentLastMonth: roundTo(fcrPercent*100, 2), }, dto.DashboardStatisticsDTO{ Label: "Mortality", Value: roundTo(mortalityCurrent, 2), - PercentLastMonth: mortalityPercent, + PercentLastMonth: roundTo(mortalityPercent*100, 2), }, ) } @@ -849,6 +836,13 @@ func buildComparisonPercentChart(seriesRows []repository.ComparisonSeries, weeks } } +func percentDelta(current, last float64) float64 { + if last <= 0 { + return 0 + } + return (current - last) / last +} + func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, filter) if err != nil {