Merge branch 'feat/BE/US-390-Dashboard' into 'development'

[FEAT/BE][US-390 dashboard calculation standart]

See merge request mbugroup/lti-api!152
This commit is contained in:
Hafizh A. Y.
2026-01-12 03:38:51 +00:00
12 changed files with 2215 additions and 0 deletions
+18
View File
@@ -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"`
}
+3
View File
@@ -1,5 +1,8 @@
package middleware package middleware
const(
P_DashboardGetAll = "lti.dashboard.list"
)
// project-flock // project-flock
const ( const (
P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing"
@@ -0,0 +1,206 @@
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("location_ids", ""))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_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
}
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).
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
}
@@ -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:"location_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
}
+26
View File
@@ -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)
}
@@ -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),
}
}
@@ -0,0 +1,725 @@
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
HenDay 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 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,
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
}
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)
}
}
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)
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
}
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")
}
}
+18
View File
@@ -0,0 +1,18 @@
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_DashboardGetAll) ,ctrl.GetAll)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
package validation
import "time"
type Create struct {
Name string `json:"name" validate:"required_strict,min=3"`
}
const (
AnalysisModeOverview = "OVERVIEW"
AnalysisModeComparison = "COMPARISON"
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 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"`
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
}
+8
View File
@@ -29,6 +29,14 @@ type SuccessWithPaginate[T any] struct {
Data []T `json:"data"` 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 { type ErrorDetails struct {
Code int `json:"code"` Code int `json:"code"`
Status string `json:"status"` Status string `json:"status"`
+2
View File
@@ -22,6 +22,7 @@ import (
repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports"
ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso"
users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users"
dashboards "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards"
// MODULE IMPORTS // MODULE IMPORTS
) )
@@ -48,6 +49,7 @@ func Routes(app *fiber.App, db *gorm.DB) {
repports.RepportModule{}, repports.RepportModule{},
finance.FinanceModule{}, finance.FinanceModule{},
dailyChecklists.DailyChecklistModule{}, dailyChecklists.DailyChecklistModule{},
dashboards.DashboardModule{},
// MODULE REGISTRY // MODULE REGISTRY
} }