Merge branch 'codex/dashboard-without-uniformity' into 'development'

Codex/dashboard without uniformity

See merge request mbugroup/lti-api!415
This commit is contained in:
Adnan Zahir
2026-04-14 14:31:56 +07:00
7 changed files with 301 additions and 108 deletions
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -105,6 +106,15 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go
return db return db
} }
func dashboardUniformityWeekExpr() string {
return fmt.Sprintf(`CASE
WHEN u.uniform_date IS NULL OR pc.chick_in_date IS NULL THEN 0
WHEN u.uniform_date::date < pc.chick_in_date THEN 0
WHEN UPPER(pf.category) = 'LAYING' THEN (((u.uniform_date::date - pc.chick_in_date)::int) / 7) + %d
ELSE (((u.uniform_date::date - pc.chick_in_date)::int) / 7) + 1
END`, config.LayingWeekStart())
}
func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) { func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) {
var rows []RecordingWeeklyMetric var rows []RecordingWeeklyMetric
@@ -140,21 +150,29 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context,
func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) { func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) {
var rows []UniformityWeeklyMetric var rows []UniformityWeeklyMetric
weekExpr := dashboardUniformityWeekExpr()
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity AS u"). Table("project_flock_kandang_uniformity AS u").
Select(`u.week AS week, Select(fmt.Sprintf(`%s AS week,
COALESCE(AVG(u.uniformity), 0) AS uniformity, COALESCE(AVG(u.uniformity), 0) AS uniformity,
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight, COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, weekExpr)).
MAX(u.uniform_date) AS uniform_date`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins(`JOIN (
SELECT project_flock_kandang_id, MIN(chick_in_date)::date AS chick_in_date
FROM project_chickins
WHERE deleted_at IS NULL
GROUP BY project_flock_kandang_id
) AS pc ON pc.project_flock_kandang_id = u.project_flock_kandang_id`).
Where("u.uniform_date IS NOT NULL"). Where("u.uniform_date IS NOT NULL").
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end) Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end).
Where("u.uniform_date::date >= pc.chick_in_date")
db = applyDashboardFilters(db, filters) db = applyDashboardFilters(db, filters)
if err := db.Group("u.week").Order("u.week ASC").Scan(&rows).Error; err != nil { if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil {
return nil, err return nil, err
} }
@@ -520,23 +538,31 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte
} }
var rows []ComparisonUniformityMetric var rows []ComparisonUniformityMetric
weekExpr := dashboardUniformityWeekExpr()
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity AS u"). Table("project_flock_kandang_uniformity AS u").
Select(fmt.Sprintf(`u.week AS week, Select(fmt.Sprintf(`%s AS week,
%s AS series_id, %s AS series_id,
COALESCE(AVG(u.uniformity), 0) AS uniformity, COALESCE(AVG(u.uniformity), 0) AS uniformity,
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, seriesExpr)). COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, weekExpr, seriesExpr)).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id"). 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 kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_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"). Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins(`JOIN (
SELECT project_flock_kandang_id, MIN(chick_in_date)::date AS chick_in_date
FROM project_chickins
WHERE deleted_at IS NULL
GROUP BY project_flock_kandang_id
) AS pc ON pc.project_flock_kandang_id = u.project_flock_kandang_id`).
Where("u.uniform_date IS NOT NULL"). Where("u.uniform_date IS NOT NULL").
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end) Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end).
Where("u.uniform_date::date >= pc.chick_in_date")
db = applyDashboardFilters(db, filters) db = applyDashboardFilters(db, filters)
groupBy := fmt.Sprintf("u.week, %s", groupExpr) groupBy := fmt.Sprintf("week, %s", groupExpr)
orderBy := fmt.Sprintf("u.week ASC, %s", orderExpr) orderBy := fmt.Sprintf("week ASC, %s", orderExpr)
if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil { if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil {
return nil, err return nil, err
} }
@@ -275,10 +275,10 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
cumFeed := 0.0 cumFeed := 0.0
for _, week := range weeks { for _, week := range weeks {
rec := recordingMap[week] rec, hasRec := recordingMap[week]
uni := uniformityMap[week] uni, hasUni := uniformityMap[week]
std := standardMap[week] std, hasStd := standardMap[week]
stdFcr := standardFcrMap[week] stdFcr, hasStdFcr := standardFcrMap[week]
weekEgg := weeklyEggMap[week] weekEgg := weeklyEggMap[week]
weekFeed := weeklyFeedMap[week] weekFeed := weeklyFeedMap[week]
@@ -294,38 +294,69 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
actFcrCum = cumFeed / cumEgg actFcrCum = cumFeed / cumEgg
} }
bodyWeightDataset = append(bodyWeightDataset, map[string]interface{}{ bodyWeightRow := map[string]interface{}{
"week": week, "week": week,
"body_weight": roundTo(uni.AverageWeight, 2), }
"std_body_weight": roundTo(std.StdBodyWeight, 2), if hasUni {
}) bodyWeightRow["body_weight"] = roundTo(uni.AverageWeight, 2)
bodyWeightDatasetIndexByWeek[week] = len(bodyWeightDataset) - 1 }
if hasStd {
bodyWeightRow["std_body_weight"] = roundTo(std.StdBodyWeight, 2)
}
if len(bodyWeightRow) > 1 {
bodyWeightDataset = append(bodyWeightDataset, bodyWeightRow)
}
performanceDataset = append(performanceDataset, map[string]interface{}{ performanceRow := map[string]interface{}{
"week": week, "week": week,
"act_laying": roundTo(rec.HenDay, 2), }
"std_laying": roundTo(std.StdLaying, 2), if hasRec {
"act_egg_weight": roundTo(rec.EggWeight, 2), performanceRow["act_laying"] = roundTo(rec.HenDay, 2)
"std_egg_weight": roundTo(std.StdEggWeight, 2), performanceRow["act_egg_weight"] = roundTo(rec.EggWeight, 2)
"act_feed_intake": roundTo(rec.FeedIntake, 2), performanceRow["act_feed_intake"] = roundTo(rec.FeedIntake, 2)
"std_feed_intake": roundTo(std.StdFeedIntake, 2), }
"act_uniformity": roundTo(uni.Uniformity, 2), if hasUni {
"std_uniformity": roundTo(std.StdUniformity, 2), performanceRow["act_uniformity"] = roundTo(uni.Uniformity, 2)
}) }
if hasStd {
performanceRow["std_laying"] = roundTo(std.StdLaying, 2)
performanceRow["std_egg_weight"] = roundTo(std.StdEggWeight, 2)
performanceRow["std_feed_intake"] = roundTo(std.StdFeedIntake, 2)
performanceRow["std_uniformity"] = roundTo(std.StdUniformity, 2)
}
if len(performanceRow) > 1 {
performanceDataset = append(performanceDataset, performanceRow)
}
fcrDataset = append(fcrDataset, map[string]interface{}{ fcrRow := map[string]interface{}{
"week": week, "week": week,
"act_fcr": roundTo(actFcr, 2), }
"std_fcr": roundTo(stdFcr, 2), if weekEgg > 0 && weekFeed > 0 {
"act_fcr_cum": roundTo(actFcrCum, 2), fcrRow["act_fcr"] = roundTo(actFcr, 2)
"std_fcr_cum": roundTo(stdFcr, 2), }
}) if cumEgg > 0 && cumFeed > 0 {
fcrRow["act_fcr_cum"] = roundTo(actFcrCum, 2)
}
if hasStdFcr {
fcrRow["std_fcr"] = roundTo(stdFcr, 2)
fcrRow["std_fcr_cum"] = roundTo(stdFcr, 2)
}
if len(fcrRow) > 1 {
fcrDataset = append(fcrDataset, fcrRow)
}
deplesiDataset = append(deplesiDataset, map[string]interface{}{ deplesiRow := map[string]interface{}{
"week": week, "week": week,
"act_deplesi": roundTo(rec.CumDepletionRate, 2), }
"std_deplesi": roundTo(std.StdDepletion, 2), if hasRec {
}) deplesiRow["act_deplesi"] = roundTo(rec.CumDepletionRate, 2)
}
if hasStd {
deplesiRow["std_deplesi"] = roundTo(std.StdDepletion, 2)
}
if len(deplesiRow) > 1 {
deplesiDataset = append(deplesiDataset, deplesiRow)
}
} }
bodyWeightDataset = extendBodyWeightDatasetUntilEndDate( bodyWeightDataset = extendBodyWeightDatasetUntilEndDate(
@@ -42,7 +42,9 @@ func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset
func (r *UniformityRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { func (r *UniformityRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB {
return db. return db.
Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.Location"). Preload("ProjectFlockKandang.ProjectFlock.Location").
Preload("ProjectFlockKandang.Chickins").
Preload("ProjectFlockKandang.Kandang.Location") Preload("ProjectFlockKandang.Kandang.Location")
} }
} }
@@ -106,6 +106,7 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent
s.Log.Errorf("Failed to get uniformitys: %+v", err) s.Log.Errorf("Failed to get uniformitys: %+v", err)
return nil, 0, err return nil, 0, err
} }
s.normalizeUniformityWeeks(uniformitys)
if err := s.attachLatestApprovals(c.Context(), uniformitys); err != nil { if err := s.attachLatestApprovals(c.Context(), uniformitys); err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -125,6 +126,7 @@ func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKa
s.Log.Errorf("Failed get uniformity by id: %+v", err) s.Log.Errorf("Failed get uniformity by id: %+v", err)
return nil, err return nil, err
} }
s.normalizeUniformityWeek(uniformity)
if err := s.attachLatestApproval(c.Context(), uniformity); err != nil { if err := s.attachLatestApproval(c.Context(), uniformity); err != nil {
return nil, err return nil, err
} }
@@ -135,6 +137,23 @@ func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlo
return s.GetOne(c, id) return s.GetOne(c, id)
} }
func (s *uniformityService) normalizeUniformityWeeks(items []entity.ProjectFlockKandangUniformity) {
for i := range items {
s.normalizeUniformityWeek(&items[i])
}
}
func (s *uniformityService) normalizeUniformityWeek(item *entity.ProjectFlockKandangUniformity) {
if item == nil || item.UniformDate == nil {
return
}
computedWeek, err := s.computeUniformityWeekForPFK(&item.ProjectFlockKandang, *item.UniformDate)
if err != nil || computedWeek <= 0 {
return
}
item.Week = computedWeek
}
func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) { func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) {
if uniformity == nil { if uniformity == nil {
return nil, nil return nil, nil
@@ -372,24 +391,18 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
} }
return nil, err return nil, err
} }
category := strings.TrimSpace(pfk.ProjectFlock.Category) computedWeek, err := s.computeUniformityWeekForPFK(pfk, uniformDate)
if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 { if err != nil {
if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil { return nil, err
if strings.TrimSpace(standard.ProjectCategory) != "" {
category = standard.ProjectCategory
}
}
} }
weekBase := 1 isGrowingCategory := strings.EqualFold(strings.TrimSpace(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryGrowing))
isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) if req.Week > 0 && req.Week != computedWeek {
if isLayingCategory { s.Log.WithFields(logrus.Fields{
weekBase = config.LayingWeekStart() "project_flock_kandang_id": req.ProjectFlockKandangId,
} "uniform_date": uniformDate.Format("2006-01-02"),
if req.Week < weekBase { "requested_week": req.Week,
if !isLayingCategory { "computed_week": computedWeek,
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") }).Warn("Uniformity week mismatch detected; using computed week")
}
// return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
} }
var latestWeek int var latestWeek int
@@ -400,17 +413,14 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
Scan(&latestWeek).Error; err != nil { Scan(&latestWeek).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence")
} }
if latestWeek == 0 && req.Week != weekBase { if latestWeek > 0 && computedWeek > latestWeek+1 {
if !isLayingCategory { return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping")
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}
// return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
} }
// if latestWeek > 0 && req.Week > latestWeek+1 { // if latestWeek > 0 && req.Week > latestWeek+1 {
// return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping") // return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping")
// } // }
if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week); err != nil { if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, computedWeek); err != nil {
return nil, err return nil, err
} }
@@ -438,7 +448,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
createBody := &entity.ProjectFlockKandangUniformity{ createBody := &entity.ProjectFlockKandangUniformity{
Uniformity: calculation.Uniformity, Uniformity: calculation.Uniformity,
Week: req.Week, Week: computedWeek,
Cv: calculation.Cv, Cv: calculation.Cv,
ChickQtyOfWeight: calculation.ChickQtyOfWeight, ChickQtyOfWeight: calculation.ChickQtyOfWeight,
MeanUp: calculation.MeanUp, MeanUp: calculation.MeanUp,
@@ -467,7 +477,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
); err != nil { ); err != nil {
return err return err
} }
if strings.EqualFold(category, string(utils.ProjectFlockCategoryGrowing)) { if isGrowingCategory {
if err := s.updateGrowingFcrForWeek(tx, createBody.ProjectFlockKandangId, createBody.Week, calculation.MeanUp); err != nil { if err := s.updateGrowingFcrForWeek(tx, createBody.ProjectFlockKandangId, createBody.Week, calculation.MeanUp); err != nil {
return err return err
} }
@@ -536,9 +546,6 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
} }
updateBody["project_flock_kandang_id"] = *req.ProjectFlockKandangId updateBody["project_flock_kandang_id"] = *req.ProjectFlockKandangId
} }
if req.Week != nil {
updateBody["week"] = *req.Week
}
if req.Date != nil || req.ProjectFlockKandangId != nil || req.Week != nil { if req.Date != nil || req.ProjectFlockKandangId != nil || req.Week != nil {
current, err := s.Repository.GetByID(c.Context(), id, nil) current, err := s.Repository.GetByID(c.Context(), id, nil)
@@ -552,15 +559,11 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
if targetDate == nil { if targetDate == nil {
targetDate = current.UniformDate targetDate = current.UniformDate
} }
targetWeek := current.Week
if req.Week != nil {
targetWeek = *req.Week
}
targetPFKID := current.ProjectFlockKandangId targetPFKID := current.ProjectFlockKandangId
if req.ProjectFlockKandangId != nil { if req.ProjectFlockKandangId != nil {
targetPFKID = *req.ProjectFlockKandangId targetPFKID = *req.ProjectFlockKandangId
} }
if targetPFKID != 0 && targetWeek > 0 { if targetPFKID != 0 && targetDate != nil {
pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetPFKID) pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetPFKID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -568,28 +571,21 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
} }
return nil, err return nil, err
} }
category := strings.TrimSpace(pfk.ProjectFlock.Category) computedWeek, err := s.computeUniformityWeekForPFK(pfk, *targetDate)
if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 { if err != nil {
if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil { return nil, err
if strings.TrimSpace(standard.ProjectCategory) != "" {
category = standard.ProjectCategory
}
}
} }
weekBase := 1 if req.Week != nil && *req.Week != computedWeek {
isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) s.Log.WithFields(logrus.Fields{
if isLayingCategory { "uniformity_id": id,
weekBase = config.LayingWeekStart() "project_flock_kandang_id": targetPFKID,
"uniform_date": targetDate.Format("2006-01-02"),
"requested_week": *req.Week,
"computed_week": computedWeek,
}).Warn("Uniformity week mismatch detected on update; using computed week")
} }
if targetWeek < weekBase { updateBody["week"] = computedWeek
if !isLayingCategory { if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, computedWeek); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
}
}
if targetDate != nil {
if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek); err != nil {
return nil, err return nil, err
} }
} }
@@ -734,6 +730,51 @@ func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint,
return nil return nil
} }
func (s *uniformityService) computeUniformityWeekForPFK(pfk *entity.ProjectFlockKandang, uniformDate time.Time) (int, error) {
if pfk == nil || pfk.Id == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
chickInDate, ok := earliestUniformityChickInDate(pfk.Chickins)
if !ok {
return 0, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in tidak ditemukan")
}
chickInDay := normalizeUniformityDateOnlyUTC(chickInDate)
uniformDay := normalizeUniformityDateOnlyUTC(uniformDate)
if uniformDay.Before(chickInDay) {
return 0, fiber.NewError(fiber.StatusBadRequest, "Uniformity date tidak boleh sebelum tanggal chick in")
}
diff := int(uniformDay.Sub(chickInDay).Hours() / 24)
weekBase := 1
if strings.EqualFold(strings.TrimSpace(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying)) {
weekBase = config.LayingWeekStart()
}
return (diff / 7) + weekBase, nil
}
func earliestUniformityChickInDate(chickins []entity.ProjectChickin) (time.Time, bool) {
var earliest time.Time
for _, chickin := range chickins {
if chickin.ChickInDate.IsZero() {
continue
}
if earliest.IsZero() || chickin.ChickInDate.Before(earliest) {
earliest = chickin.ChickInDate
}
}
if earliest.IsZero() {
return time.Time{}, false
}
return earliest, true
}
func normalizeUniformityDateOnlyUTC(value time.Time) time.Time {
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
}
func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error { func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil { if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil {
return err return err
@@ -0,0 +1,78 @@
package service
import (
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
func TestComputeUniformityWeekForPFK(t *testing.T) {
originalWeekStart := config.TransferToLayingGrowingMaxWeek
config.TransferToLayingGrowingMaxWeek = 19
t.Cleanup(func() {
config.TransferToLayingGrowingMaxWeek = originalWeekStart
})
svc := &uniformityService{}
baseDate := time.Date(2026, time.January, 1, 9, 30, 0, 0, time.UTC)
t.Run("growing starts from week one", func(t *testing.T) {
pfk := &entity.ProjectFlockKandang{
Id: 1,
ProjectFlock: entity.ProjectFlock{
Category: string(utils.ProjectFlockCategoryGrowing),
},
Chickins: []entity.ProjectChickin{
{ChickInDate: baseDate},
},
}
week, err := svc.computeUniformityWeekForPFK(pfk, baseDate)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if week != 1 {
t.Fatalf("expected week 1, got %d", week)
}
})
t.Run("laying uses configured week base and earliest chick in", func(t *testing.T) {
pfk := &entity.ProjectFlockKandang{
Id: 2,
ProjectFlock: entity.ProjectFlock{
Category: string(utils.ProjectFlockCategoryLaying),
},
Chickins: []entity.ProjectChickin{
{ChickInDate: baseDate.AddDate(0, 0, 4)},
{ChickInDate: baseDate},
},
}
week, err := svc.computeUniformityWeekForPFK(pfk, baseDate.AddDate(0, 0, 7))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if week != 20 {
t.Fatalf("expected week 20, got %d", week)
}
})
t.Run("rejects date before chick in", func(t *testing.T) {
pfk := &entity.ProjectFlockKandang{
Id: 3,
ProjectFlock: entity.ProjectFlock{
Category: string(utils.ProjectFlockCategoryLaying),
},
Chickins: []entity.ProjectChickin{
{ChickInDate: baseDate},
},
}
if _, err := svc.computeUniformityWeekForPFK(pfk, baseDate.AddDate(0, 0, -1)); err == nil {
t.Fatal("expected error for date before chick in")
}
})
}
@@ -12,7 +12,7 @@ import (
type Create struct { type Create struct {
Date string `form:"date" validate:"required"` Date string `form:"date" validate:"required"`
ProjectFlockKandangId uint `form:"project_flock_kandang_id" validate:"required,number,min=1"` ProjectFlockKandangId uint `form:"project_flock_kandang_id" validate:"required,number,min=1"`
Week int `form:"week" validate:"required,min=1"` Week int `form:"week" validate:"omitempty,min=1"`
} }
type Update struct { type Update struct {
@@ -120,14 +120,14 @@ func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
} }
week := 0
weekStr := strings.TrimSpace(c.FormValue("week")) weekStr := strings.TrimSpace(c.FormValue("week"))
if weekStr == "" { if weekStr != "" {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required") parsedWeek, err := strconv.Atoi(weekStr)
} if err != nil || parsedWeek <= 0 {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is invalid")
week, err := strconv.Atoi(weekStr) }
if err != nil || week <= 0 { week = parsedWeek
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required")
} }
file, err := c.FormFile("document") file, err := c.FormFile("document")
@@ -939,12 +939,27 @@ func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKa
return result, nil return result, nil
} }
weekExpr := fmt.Sprintf(`CASE
WHEN u.uniform_date IS NULL OR pc.chick_in_date IS NULL THEN 0
WHEN u.uniform_date::date < pc.chick_in_date THEN 0
WHEN UPPER(pf.category) = 'LAYING' THEN (((u.uniform_date::date - pc.chick_in_date)::int) / 7) + %d
ELSE (((u.uniform_date::date - pc.chick_in_date)::int) / 7) + 1
END`, config.LayingWeekStart())
var rows []entity.ProjectFlockKandangUniformity var rows []entity.ProjectFlockKandangUniformity
if err := s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Model(&entity.ProjectFlockKandangUniformity{}). Table("project_flock_kandang_uniformity AS u").
Select("week, uniformity, uniform_date, id, chart_data"). Select(fmt.Sprintf("%s AS week, u.uniformity, u.uniform_date, u.id, u.chart_data", weekExpr)).
Where("project_flock_kandang_id = ?", projectFlockKandangID). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id").
Where("week IN ?", weeks). Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins(`JOIN (
SELECT project_flock_kandang_id, MIN(chick_in_date)::date AS chick_in_date
FROM project_chickins
WHERE deleted_at IS NULL
GROUP BY project_flock_kandang_id
) AS pc ON pc.project_flock_kandang_id = u.project_flock_kandang_id`).
Where("u.project_flock_kandang_id = ?", projectFlockKandangID).
Where(fmt.Sprintf("%s IN ?", weekExpr), weeks).
Order("uniform_date DESC"). Order("uniform_date DESC").
Order("id DESC"). Order("id DESC").
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {