From 7638c183f50eee0b20a6c0da8fcc46a3bf8417dc Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Wed, 8 Apr 2026 15:13:31 +0700 Subject: [PATCH 1/3] codex/fix: dashboard independent recording values without uniformity --- .../dashboards/services/dashboard.service.go | 96 ++++++++++++------- 1 file changed, 64 insertions(+), 32 deletions(-) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 928205d2..4ad5cc8e 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -274,10 +274,10 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va cumFeed := 0.0 for _, week := range weeks { - rec := recordingMap[week] - uni := uniformityMap[week] - std := standardMap[week] - stdFcr := standardFcrMap[week] + rec, hasRec := recordingMap[week] + uni, hasUni := uniformityMap[week] + std, hasStd := standardMap[week] + stdFcr, hasStdFcr := standardFcrMap[week] weekEgg := weeklyEggMap[week] weekFeed := weeklyFeedMap[week] @@ -293,37 +293,69 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va actFcrCum = cumFeed / cumEgg } - bodyWeightDataset = append(bodyWeightDataset, map[string]interface{}{ - "week": week, - "body_weight": roundTo(uni.AverageWeight, 2), - "std_body_weight": roundTo(std.StdBodyWeight, 2), - }) + bodyWeightRow := map[string]interface{}{ + "week": week, + } + if hasUni { + bodyWeightRow["body_weight"] = roundTo(uni.AverageWeight, 2) + } + if hasStd { + bodyWeightRow["std_body_weight"] = roundTo(std.StdBodyWeight, 2) + } + if len(bodyWeightRow) > 1 { + bodyWeightDataset = append(bodyWeightDataset, bodyWeightRow) + } - performanceDataset = append(performanceDataset, map[string]interface{}{ - "week": week, - "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), - "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), - }) + performanceRow := map[string]interface{}{ + "week": week, + } + if hasRec { + performanceRow["act_laying"] = roundTo(rec.HenDay, 2) + performanceRow["act_egg_weight"] = roundTo(rec.EggWeight, 2) + performanceRow["act_feed_intake"] = roundTo(rec.FeedIntake, 2) + } + if hasUni { + 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{}{ - "week": week, - "act_fcr": roundTo(actFcr, 2), - "std_fcr": roundTo(stdFcr, 2), - "act_fcr_cum": roundTo(actFcrCum, 2), - "std_fcr_cum": roundTo(stdFcr, 2), - }) + fcrRow := map[string]interface{}{ + "week": week, + } + if weekEgg > 0 && weekFeed > 0 { + fcrRow["act_fcr"] = roundTo(actFcr, 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{}{ - "week": week, - "act_deplesi": roundTo(rec.CumDepletionRate, 2), - "std_deplesi": roundTo(std.StdDepletion, 2), - }) + deplesiRow := map[string]interface{}{ + "week": week, + } + 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) + } } qualityRows, err := s.Repository.GetEggQualityWeeklyMetrics(ctx, startDate, endExclusive, filter) From ca698ff2ae59b31daa090740bbf1a948698ae8bd Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Tue, 14 Apr 2026 13:09:47 +0700 Subject: [PATCH 2/3] codex/fix: uniformity week calculation --- .../dashboard_stats.repository.go | 45 ++++-- .../repositories/uniformity.repository.go | 2 + .../services/uniformity.service.go | 148 +++++++++++------- .../services/uniformity_week_test.go | 78 +++++++++ .../validations/uniformity.validation.go | 16 +- .../repports/services/repport.service.go | 23 ++- 6 files changed, 236 insertions(+), 76 deletions(-) create mode 100644 internal/modules/production/uniformities/services/uniformity_week_test.go diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 363e6aa5..a7743c37 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/config" 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" @@ -104,6 +105,15 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go 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) { var rows []RecordingWeeklyMetric @@ -139,20 +149,29 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) { var rows []UniformityWeeklyMetric + weekExpr := dashboardUniformityWeekExpr() db := r.DB().WithContext(ctx). 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.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`). + COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, weekExpr)). 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 ( + 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 >= ? 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) - 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 } @@ -518,23 +537,31 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte } var rows []ComparisonUniformityMetric + weekExpr := dashboardUniformityWeekExpr() db := r.DB().WithContext(ctx). Table("project_flock_kandang_uniformity AS u"). - Select(fmt.Sprintf(`u.week AS week, + Select(fmt.Sprintf(`%s 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)). + 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 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"). + 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 >= ? 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) - groupBy := fmt.Sprintf("u.week, %s", groupExpr) - orderBy := fmt.Sprintf("u.week ASC, %s", orderExpr) + 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 } diff --git a/internal/modules/production/uniformities/repositories/uniformity.repository.go b/internal/modules/production/uniformities/repositories/uniformity.repository.go index 8453bf84..e13e882e 100644 --- a/internal/modules/production/uniformities/repositories/uniformity.repository.go +++ b/internal/modules/production/uniformities/repositories/uniformity.repository.go @@ -42,7 +42,9 @@ func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset func (r *UniformityRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db. + Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock.Location"). + Preload("ProjectFlockKandang.Chickins"). Preload("ProjectFlockKandang.Kandang.Location") } } diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 7de39ef8..143f4e93 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -106,6 +106,7 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent s.Log.Errorf("Failed to get uniformitys: %+v", err) return nil, 0, err } + s.normalizeUniformityWeeks(uniformitys) if err := s.attachLatestApprovals(c.Context(), uniformitys); err != nil { 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) return nil, err } + s.normalizeUniformityWeek(uniformity) if err := s.attachLatestApproval(c.Context(), uniformity); err != nil { return nil, err } @@ -135,6 +137,23 @@ func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlo 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) { if uniformity == nil { return nil, nil @@ -372,24 +391,18 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file } return nil, err } - category := strings.TrimSpace(pfk.ProjectFlock.Category) - if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 { - if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil { - if strings.TrimSpace(standard.ProjectCategory) != "" { - category = standard.ProjectCategory - } - } + computedWeek, err := s.computeUniformityWeekForPFK(pfk, uniformDate) + if err != nil { + return nil, err } - weekBase := 1 - isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) - if isLayingCategory { - weekBase = config.LayingWeekStart() - } - if req.Week < weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) - } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + isGrowingCategory := strings.EqualFold(strings.TrimSpace(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryGrowing)) + if req.Week > 0 && req.Week != computedWeek { + s.Log.WithFields(logrus.Fields{ + "project_flock_kandang_id": req.ProjectFlockKandangId, + "uniform_date": uniformDate.Format("2006-01-02"), + "requested_week": req.Week, + "computed_week": computedWeek, + }).Warn("Uniformity week mismatch detected; using computed week") } var latestWeek int @@ -400,17 +413,11 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file Scan(&latestWeek).Error; err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence") } - if latestWeek == 0 && req.Week != weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) - } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") - } - if latestWeek > 0 && req.Week > latestWeek+1 { + if latestWeek > 0 && computedWeek > latestWeek+1 { 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 } @@ -438,7 +445,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file createBody := &entity.ProjectFlockKandangUniformity{ Uniformity: calculation.Uniformity, - Week: req.Week, + Week: computedWeek, Cv: calculation.Cv, ChickQtyOfWeight: calculation.ChickQtyOfWeight, MeanUp: calculation.MeanUp, @@ -467,7 +474,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file ); err != nil { return err } - if strings.EqualFold(category, string(utils.ProjectFlockCategoryGrowing)) { + if isGrowingCategory { if err := s.updateGrowingFcrForWeek(tx, createBody.ProjectFlockKandangId, createBody.Week, calculation.MeanUp); err != nil { return err } @@ -536,9 +543,6 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui } 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 { current, err := s.Repository.GetByID(c.Context(), id, nil) @@ -552,15 +556,11 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui if targetDate == nil { targetDate = current.UniformDate } - targetWeek := current.Week - if req.Week != nil { - targetWeek = *req.Week - } targetPFKID := current.ProjectFlockKandangId if req.ProjectFlockKandangId != nil { targetPFKID = *req.ProjectFlockKandangId } - if targetPFKID != 0 && targetWeek > 0 { + if targetPFKID != 0 && targetDate != nil { pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetPFKID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -568,28 +568,21 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui } return nil, err } - category := strings.TrimSpace(pfk.ProjectFlock.Category) - if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 { - if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil { - if strings.TrimSpace(standard.ProjectCategory) != "" { - category = standard.ProjectCategory - } - } + computedWeek, err := s.computeUniformityWeekForPFK(pfk, *targetDate) + if err != nil { + return nil, err } - weekBase := 1 - isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) - if isLayingCategory { - weekBase = config.LayingWeekStart() + if req.Week != nil && *req.Week != computedWeek { + s.Log.WithFields(logrus.Fields{ + "uniformity_id": id, + "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 { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) - } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") - } - } - if targetDate != nil { - if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek); err != nil { + updateBody["week"] = computedWeek + if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, computedWeek); err != nil { return nil, err } } @@ -734,6 +727,51 @@ func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, 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 { if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil { return err diff --git a/internal/modules/production/uniformities/services/uniformity_week_test.go b/internal/modules/production/uniformities/services/uniformity_week_test.go new file mode 100644 index 00000000..54dbfc2e --- /dev/null +++ b/internal/modules/production/uniformities/services/uniformity_week_test.go @@ -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") + } + }) +} diff --git a/internal/modules/production/uniformities/validations/uniformity.validation.go b/internal/modules/production/uniformities/validations/uniformity.validation.go index e4f7f8a0..651a29e9 100644 --- a/internal/modules/production/uniformities/validations/uniformity.validation.go +++ b/internal/modules/production/uniformities/validations/uniformity.validation.go @@ -12,7 +12,7 @@ import ( type Create struct { Date string `form:"date" validate:"required"` 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 { @@ -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") } + week := 0 weekStr := strings.TrimSpace(c.FormValue("week")) - if weekStr == "" { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required") - } - - week, err := strconv.Atoi(weekStr) - if err != nil || week <= 0 { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required") + if weekStr != "" { + parsedWeek, err := strconv.Atoi(weekStr) + if err != nil || parsedWeek <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is invalid") + } + week = parsedWeek } file, err := c.FormFile("document") diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 3468f266..b866cf96 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -939,12 +939,27 @@ func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKa 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 if err := s.db.WithContext(ctx). - Model(&entity.ProjectFlockKandangUniformity{}). - Select("week, uniformity, uniform_date, id, chart_data"). - Where("project_flock_kandang_id = ?", projectFlockKandangID). - Where("week IN ?", weeks). + Table("project_flock_kandang_uniformity AS u"). + Select(fmt.Sprintf("%s AS week, u.uniformity, u.uniform_date, u.id, u.chart_data", weekExpr)). + 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 ( + 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("id DESC"). Find(&rows).Error; err != nil { From cd549de57843cec5c79e37c282c11b2167fe9f03 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 14 Apr 2026 14:48:56 +0700 Subject: [PATCH 3/3] adjust edit delivery order; add migration for delivery order; adjust response get marketing --- ...2_add_field_weight_per_convertion.down.sql | 3 + ...542_add_field_weight_per_convertion.up.sql | 4 + .../entities/marketing_delivery_product.go | 1 + .../marketing/dto/deliveryorder.dto.go | 80 +++++++++++-------- .../services/deliveryorder.service.go | 52 ++++++++++-- .../marketing/services/salesorder.service.go | 9 +-- .../validations/deliveryorder.validation.go | 15 ++-- 7 files changed, 111 insertions(+), 53 deletions(-) create mode 100644 internal/database/migrations/20260414064542_add_field_weight_per_convertion.down.sql create mode 100644 internal/database/migrations/20260414064542_add_field_weight_per_convertion.up.sql diff --git a/internal/database/migrations/20260414064542_add_field_weight_per_convertion.down.sql b/internal/database/migrations/20260414064542_add_field_weight_per_convertion.down.sql new file mode 100644 index 00000000..489fce73 --- /dev/null +++ b/internal/database/migrations/20260414064542_add_field_weight_per_convertion.down.sql @@ -0,0 +1,3 @@ +-- Remove convertion fields from marketing_delivery_products table +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS weight_per_convertion; diff --git a/internal/database/migrations/20260414064542_add_field_weight_per_convertion.up.sql b/internal/database/migrations/20260414064542_add_field_weight_per_convertion.up.sql new file mode 100644 index 00000000..bf7b8bfc --- /dev/null +++ b/internal/database/migrations/20260414064542_add_field_weight_per_convertion.up.sql @@ -0,0 +1,4 @@ +-- Add convertion fields to marketing_delivery_products table +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS weight_per_convertion NUMERIC(15, 3); + diff --git a/internal/entities/marketing_delivery_product.go b/internal/entities/marketing_delivery_product.go index 78ca61ab..2d20fca4 100644 --- a/internal/entities/marketing_delivery_product.go +++ b/internal/entities/marketing_delivery_product.go @@ -12,6 +12,7 @@ type MarketingDeliveryProduct struct { UnitPrice float64 `gorm:"type:numeric(15,3)"` TotalWeight float64 `gorm:"type:numeric(15,3)"` AvgWeight float64 `gorm:"type:numeric(15,3)"` + WeightPerConvertion *float64 `gorm:"type:numeric(15,3)"` TotalPrice float64 `gorm:"type:numeric(15,3)"` DeliveryDate *time.Time `gorm:"type:timestamptz"` VehicleNumber string `gorm:"type:varchar(50)"` diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index 20b3e42b..e8955d34 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -49,26 +49,30 @@ type MarketingDetailDTO struct { } type MarketingDeliveryProductDTO struct { - Id uint `json:"id"` - MarketingProductId uint `json:"marketing_product_id"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - TotalWeight float64 `json:"total_weight"` - AvgWeight float64 `json:"avg_weight"` - TotalPrice float64 `json:"total_price"` - DeliveryDate *time.Time `json:"delivery_date"` - VehicleNumber string `json:"vehicle_number"` - ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` + Id uint `json:"id"` + MarketingProductId uint `json:"marketing_product_id"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + TotalWeight float64 `json:"total_weight"` + AvgWeight float64 `json:"avg_weight"` + TotalPrice float64 `json:"total_price"` + DeliveryDate *time.Time `json:"delivery_date"` + VehicleNumber string `json:"vehicle_number"` + ConvertionUnit *string `json:"-"` + WeightPerConvertion *float64 `json:"-"` + ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` } type DeliveryItemDTO struct { - ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - TotalWeight float64 `json:"total_weight"` - AvgWeight float64 `json:"avg_weight"` - TotalPrice float64 `json:"total_price"` - VehicleNumber string `json:"vehicle_number"` + ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + TotalWeight float64 `json:"total_weight"` + AvgWeight float64 `json:"avg_weight"` + WeightPerConvertion *float64 `json:"weight_per_convertion"` + TotalPeti *float64 `json:"total_peti"` + TotalPrice float64 `json:"total_price"` + VehicleNumber string `json:"vehicle_number"` } type DeliveryGroupDTO struct { @@ -147,15 +151,16 @@ func ToDeliveryMarketingProductDTO(e entity.MarketingProduct, marketingType stri func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingDeliveryProductDTO { return MarketingDeliveryProductDTO{ - Id: e.Id, - MarketingProductId: e.MarketingProductId, - Qty: e.UsageQty, - UnitPrice: e.UnitPrice, - TotalWeight: e.TotalWeight, - AvgWeight: e.AvgWeight, - TotalPrice: e.TotalPrice, - DeliveryDate: e.DeliveryDate, - VehicleNumber: e.VehicleNumber, + Id: e.Id, + MarketingProductId: e.MarketingProductId, + Qty: e.UsageQty, + UnitPrice: e.UnitPrice, + TotalWeight: e.TotalWeight, + AvgWeight: e.AvgWeight, + TotalPrice: e.TotalPrice, + DeliveryDate: e.DeliveryDate, + VehicleNumber: e.VehicleNumber, + WeightPerConvertion: e.WeightPerConvertion, } } @@ -285,6 +290,7 @@ func enrichDeliveryProductDTOsWithWarehouse(deliveryProductDTOs []MarketingDeliv mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(product.ProductWarehouse) deliveryProductDTOs[i].ProductWarehouse = &mapped } + deliveryProductDTOs[i].ConvertionUnit = product.ConvertionUnit } } @@ -322,13 +328,21 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri } deliveryItem := DeliveryItemDTO{ - ProductWarehouse: product.ProductWarehouse, - Qty: product.Qty, - UnitPrice: product.UnitPrice, - TotalWeight: product.TotalWeight, - AvgWeight: product.AvgWeight, - TotalPrice: product.TotalPrice, - VehicleNumber: product.VehicleNumber, + ProductWarehouse: product.ProductWarehouse, + Qty: product.Qty, + UnitPrice: product.UnitPrice, + TotalWeight: product.TotalWeight, + AvgWeight: product.AvgWeight, + WeightPerConvertion: product.WeightPerConvertion, + TotalPrice: product.TotalPrice, + VehicleNumber: product.VehicleNumber, + } + if product.ConvertionUnit != nil && + strings.EqualFold(*product.ConvertionUnit, "PETI") && + product.WeightPerConvertion != nil && + *product.WeightPerConvertion > 0 { + totalPeti := product.TotalWeight / *product.WeightPerConvertion + deliveryItem.TotalPeti = &totalPeti } group.Deliveries = append(group.Deliveries, deliveryItem) } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 2ae8dec9..34c415c3 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "strings" "time" @@ -375,11 +376,12 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week) + totalWeight, totalPrice := s.resolveDeliveryTotals(marketing.MarketingType, requestedProduct, foundMarketingProduct) deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight + deliveryProduct.WeightPerConvertion = requestedProduct.WeightPerConvertion deliveryProduct.TotalWeight = totalWeight deliveryProduct.TotalPrice = totalPrice deliveryProduct.DeliveryDate = itemDeliveryDate @@ -498,11 +500,12 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO itemDeliveryDate = deliveryProduct.DeliveryDate } - totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week) + totalWeight, totalPrice := s.resolveDeliveryTotals(marketing.MarketingType, requestedProduct, foundMarketingProduct) deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight + deliveryProduct.WeightPerConvertion = requestedProduct.WeightPerConvertion deliveryProduct.TotalWeight = totalWeight deliveryProduct.TotalPrice = totalPrice deliveryProduct.DeliveryDate = itemDeliveryDate @@ -541,20 +544,53 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return s.getMarketingWithDeliveries(c, id) } -func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) { +func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, _ *float64) (totalWeight, totalPrice float64) { if marketingType == string(utils.MarketingTypeTrading) { totalWeight = 0 - totalPrice = qty * unitPrice + totalPrice = math.Round(qty*unitPrice*100) / 100 } else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 { - totalWeight = qty * avgWeight - totalPrice = unitPrice * float64(*week) * qty + totalWeight = math.Round(qty*avgWeight*100) / 100 + totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100 } else { - totalWeight = qty * avgWeight - totalPrice = totalWeight * unitPrice + totalWeight = math.Round(qty*avgWeight*100) / 100 + + if marketingType == string(utils.MarketingTypeTelur) && convertionUnit != nil { + switch *convertionUnit { + case string(utils.ConvertionUnitQty): + totalPrice = math.Round(qty*unitPrice*100) / 100 + return totalWeight, totalPrice + case string(utils.ConvertionUnitPeti): + totalPrice = math.Round(totalWeight*unitPrice*100) / 100 + return totalWeight, totalPrice + } + } + + totalPrice = math.Round(totalWeight*unitPrice*100) / 100 } return totalWeight, totalPrice } +func (s *deliveryOrdersService) resolveDeliveryTotals(marketingType string, requestedProduct validation.DeliveryProduct, marketingProduct *entity.MarketingProduct) (totalWeight, totalPrice float64) { + totalWeight, totalPrice = s.calculatePriceByMarketingType( + marketingType, + requestedProduct.Qty, + requestedProduct.AvgWeight, + requestedProduct.UnitPrice, + marketingProduct.Week, + marketingProduct.ConvertionUnit, + marketingProduct.WeightPerConvertion, + ) + + if requestedProduct.TotalWeight != nil { + totalWeight = *requestedProduct.TotalWeight + } + if requestedProduct.TotalPrice != nil { + totalPrice = *requestedProduct.TotalPrice + } + + return totalWeight, totalPrice +} + func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64, actorID uint) error { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 858799df..5f646112 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -815,7 +815,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont return nil } -func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, weightPerConvertion *float64) (totalWeight, totalPrice float64) { +func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, _ *float64) (totalWeight, totalPrice float64) { if marketingType == string(utils.MarketingTypeTrading) { totalWeight = 0 totalPrice = math.Round(qty*unitPrice*100) / 100 @@ -831,11 +831,8 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, totalPrice = math.Round(qty*unitPrice*100) / 100 return totalWeight, totalPrice case string(utils.ConvertionUnitPeti): - if weightPerConvertion != nil && *weightPerConvertion > 0 { - totalPeti := totalWeight / *weightPerConvertion - totalPrice = math.Round(totalPeti*unitPrice*100) / 100 - return totalWeight, totalPrice - } + totalPrice = math.Round(totalWeight*unitPrice*100) / 100 + return totalWeight, totalPrice } } diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index e4687fad..4b7c1328 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -1,12 +1,15 @@ package validation type DeliveryProduct struct { - MarketingProductId uint `json:"marketing_product_id" validate:"required,gt=0"` - Qty float64 `json:"qty" validate:"omitempty,gte=0"` - UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"` - AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"` - DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"` - VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"` + MarketingProductId uint `json:"marketing_product_id" validate:"required,gt=0"` + Qty float64 `json:"qty" validate:"omitempty,gte=0"` + UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"` + AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"` + WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gt=0"` + TotalWeight *float64 `json:"total_weight" validate:"omitempty,gte=0"` + TotalPrice *float64 `json:"total_price" validate:"omitempty,gte=0"` + DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"` + VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"` } type DeliveryOrderCreate struct {