From 36b0f97897f9d6f4fb676d4e585807b0aa0b45c7 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 2 Apr 2026 11:24:53 +0700 Subject: [PATCH 01/11] fix upser daily checklist status rejected; fix search list daily checklist --- ...ily_checklist_unique_for_rejected.down.sql | 5 ++ ...daily_checklist_unique_for_rejected.up.sql | 6 ++ .../services/daily-checklist.service.go | 73 +++++++++++++++---- 3 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.down.sql create mode 100644 internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.up.sql diff --git a/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.down.sql b/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.down.sql new file mode 100644 index 00000000..1994f220 --- /dev/null +++ b/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected; + +ALTER TABLE daily_checklists + ADD CONSTRAINT daily_checklists_date_kandang_category_key + UNIQUE (date, kandang_id, category); diff --git a/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.up.sql b/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.up.sql new file mode 100644 index 00000000..83ea4f41 --- /dev/null +++ b/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE daily_checklists + DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected + ON daily_checklists (date, kandang_id, category) + WHERE (status IS NULL OR status <> 'REJECTED'); diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 6330e641..14937e8b 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -261,8 +261,11 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([ if params.Search != "" { re := regexp.MustCompile("[^a-zA-Z0-9]") - like := re.ReplaceAll([]byte("%"+params.Search+"%"), []byte("")) - db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", string(like), string(like)) + normalizedSearch := re.ReplaceAllString(params.Search, "") + if normalizedSearch != "" { + like := "%" + normalizedSearch + "%" + db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", like, like) + } } countDB := db.Session(&gorm.Session{}) @@ -504,24 +507,66 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) status := req.Status category := req.Category + targetID := uint(0) - createBody := &entity.DailyChecklist{ - KandangId: req.KandangId, - Date: date, - Category: category, - Status: &status, - } + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + existing := new(entity.DailyChecklist) + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED"). + Take(existing).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } - err = s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "date"}, {Name: "kandang_id"}, {Name: "category"}}, - DoUpdates: clause.Assignments(map[string]any{"updated_at": time.Now()}), - }).Create(createBody).Error + if err == nil { + if err := tx.Model(&entity.DailyChecklist{}). + Where("id = ?", existing.Id). + Update("updated_at", time.Now()).Error; err != nil { + return err + } + + targetID = existing.Id + return nil + } + + createStatus := status + var rejectedCount int64 + if err := tx.Model(&entity.DailyChecklist{}). + Where("date = ? AND kandang_id = ? AND category = ? AND status = ?", date, req.KandangId, category, "REJECTED"). + Count(&rejectedCount).Error; err != nil { + return err + } + if rejectedCount > 0 { + createStatus = "DRAFT" + } + + createBody := &entity.DailyChecklist{ + KandangId: req.KandangId, + Date: date, + Category: category, + Status: &createStatus, + } + + if err := tx.Create(createBody).Error; err != nil { + // Handle concurrent insert for active checklist with same key. + if findErr := tx. + Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED"). + Take(existing).Error; findErr == nil { + targetID = existing.Id + return nil + } + return err + } + + targetID = createBody.Id + return nil + }) if err != nil { - s.Log.Errorf("Failed to upsert dailyChecklist: %+v", err) + s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err) return nil, err } - return s.GetOne(c, createBody.Id) + return s.GetOne(c, targetID) } func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) { From 88b6e2f294b01ffd2880207bb88d066f70c3451b Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 2 Apr 2026 11:40:38 +0700 Subject: [PATCH 02/11] adjust sql migration --- ...034456_adjust_daily_checklist_unique_for_rejected.down.sql | 4 ++++ ...02034456_adjust_daily_checklist_unique_for_rejected.up.sql | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.down.sql b/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.down.sql index 1994f220..2ef9aecd 100644 --- a/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.down.sql +++ b/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.down.sql @@ -1,5 +1,9 @@ +BEGIN; + DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected; ALTER TABLE daily_checklists ADD CONSTRAINT daily_checklists_date_kandang_category_key UNIQUE (date, kandang_id, category); + +COMMIT; diff --git a/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.up.sql b/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.up.sql index 83ea4f41..753deaef 100644 --- a/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.up.sql +++ b/internal/database/migrations/20260402034456_adjust_daily_checklist_unique_for_rejected.up.sql @@ -1,6 +1,10 @@ +BEGIN; + ALTER TABLE daily_checklists DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key; CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected ON daily_checklists (date, kandang_id, category) WHERE (status IS NULL OR status <> 'REJECTED'); + +COMMIT; From 0ac40adb5afd2e04a1344246d1f50d79ec20c064 Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 4 May 2026 11:30:53 +0700 Subject: [PATCH 03/11] adjust calculate umur ayam at recording --- .../service/common.depreciation.service.go | 2 +- ...adjust_recording_day_zero_indexed.down.sql | 14 +++++++++ ...4_adjust_recording_day_zero_indexed.up.sql | 14 +++++++++ .../dashboard_stats.repository.go | 30 +++++++++---------- .../recordings/services/recording.service.go | 6 ++-- 5 files changed, 47 insertions(+), 19 deletions(-) create mode 100644 internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.down.sql create mode 100644 internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.up.sql diff --git a/internal/common/service/common.depreciation.service.go b/internal/common/service/common.depreciation.service.go index 6f12e077..e73e601a 100644 --- a/internal/common/service/common.depreciation.service.go +++ b/internal/common/service/common.depreciation.service.go @@ -31,7 +31,7 @@ func FlockAgeDay(originDate time.Time, periodDate time.Time) int { if period.Before(origin) { return 0 } - return int(period.Sub(origin).Hours()/24) + 1 + return int(period.Sub(origin).Hours() / 24) } func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int { diff --git a/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.down.sql b/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.down.sql new file mode 100644 index 00000000..2f92644d --- /dev/null +++ b/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.down.sql @@ -0,0 +1,14 @@ +UPDATE recordings r +SET day = ( + SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int + 1 + FROM project_chickins pc + WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id + AND pc.deleted_at IS NULL +) +WHERE r.deleted_at IS NULL + AND ( + SELECT MIN(pc.chick_in_date) + FROM project_chickins pc + WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id + AND pc.deleted_at IS NULL + ) IS NOT NULL; diff --git a/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.up.sql b/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.up.sql new file mode 100644 index 00000000..67937750 --- /dev/null +++ b/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.up.sql @@ -0,0 +1,14 @@ +UPDATE recordings r +SET day = ( + SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int + FROM project_chickins pc + WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id + AND pc.deleted_at IS NULL +) +WHERE r.deleted_at IS NULL + AND ( + SELECT MIN(pc.chick_in_date) + FROM project_chickins pc + WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id + AND pc.deleted_at IS NULL + ) IS NOT NULL; diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index c79879fb..4197fd69 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -119,9 +119,9 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, var rows []RecordingWeeklyMetric weekExpr := `CASE - WHEN r.day IS NULL OR r.day <= 0 THEN 1 - WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 - ELSE ((r.day - 1) / 7 + 1) + WHEN r.day IS NULL OR r.day < 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17 + ELSE (r.day / 7 + 1) END` db := r.DB().WithContext(ctx). @@ -503,9 +503,9 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context var rows []ComparisonWeeklyMetric weekExpr := `CASE - WHEN r.day IS NULL OR r.day <= 0 THEN 1 - WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 - ELSE ((r.day - 1) / 7 + 1) + WHEN r.day IS NULL OR r.day < 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17 + ELSE (r.day / 7 + 1) END` db := r.DB().WithContext(ctx). @@ -574,9 +574,9 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context var rows []EggQualityWeeklyMetric weekExpr := `CASE - WHEN r.day IS NULL OR r.day <= 0 THEN 1 - WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 - ELSE ((r.day - 1) / 7 + 1) + WHEN r.day IS NULL OR r.day < 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17 + ELSE (r.day / 7 + 1) END` db := r.DB().WithContext(ctx). @@ -616,9 +616,9 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s var rows []WeeklyEggWeightMetric weekExpr := `CASE - WHEN r.day IS NULL OR r.day <= 0 THEN 1 - WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 - ELSE ((r.day - 1) / 7 + 1) + WHEN r.day IS NULL OR r.day < 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17 + ELSE (r.day / 7 + 1) END` db := r.DB().WithContext(ctx). @@ -647,9 +647,9 @@ func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, s var rows []WeeklyFeedUsageMetric weekExpr := `CASE - WHEN r.day IS NULL OR r.day <= 0 THEN 1 - WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 - ELSE ((r.day - 1) / 7 + 1) + WHEN r.day IS NULL OR r.day < 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17 + ELSE (r.day / 7 + 1) END` db := r.DB().WithContext(ctx). diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index c7bfe648..5264e32a 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -2460,7 +2460,7 @@ func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlock return 0, fiber.NewError(fiber.StatusBadRequest, "Record date tidak boleh sebelum tanggal chick in") } - return diff + 1, nil + return diff, nil } func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error { @@ -2621,8 +2621,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm if isGrowing { week := 0 - if recording.Day != nil && *recording.Day > 0 { - week = (*recording.Day-1)/7 + 1 + if recording.Day != nil && *recording.Day >= 0 { + week = *recording.Day/7 + 1 } if week > 0 && s.Repository != nil { meanBw, ok, err := s.Repository.GetUniformityMeanBwByWeek(tx, recording.ProjectFlockKandangId, week) From b2be67e0529e4acb354cbc6c32720597f6f39c97 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 4 May 2026 11:54:19 +0700 Subject: [PATCH 04/11] fix: add sort_by and order_by query in master data kandang and kandang groups API --- .../controllers/kandang_group.controller.go | 2 ++ .../kandang-groups/services/kandang_group.service.go | 9 ++++++++- .../validations/kandang_group.validation.go | 2 ++ .../master/kandangs/controllers/kandang.controller.go | 2 ++ .../modules/master/kandangs/services/kandang.service.go | 9 ++++++++- .../master/kandangs/validations/kandang.validation.go | 2 ++ 6 files changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/modules/master/kandang-groups/controllers/kandang_group.controller.go b/internal/modules/master/kandang-groups/controllers/kandang_group.controller.go index a7039f67..762713f8 100644 --- a/internal/modules/master/kandang-groups/controllers/kandang_group.controller.go +++ b/internal/modules/master/kandang-groups/controllers/kandang_group.controller.go @@ -29,6 +29,8 @@ func (u *KandangGroupController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), LocationId: c.QueryInt("location_id", 0), PicId: c.QueryInt("pic_id", 0), + OrderBy: c.Query("order_by", "desc"), + SortBy: c.Query("sort_by", "updated_at"), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/kandang-groups/services/kandang_group.service.go b/internal/modules/master/kandang-groups/services/kandang_group.service.go index b569ca97..4293bfce 100644 --- a/internal/modules/master/kandang-groups/services/kandang_group.service.go +++ b/internal/modules/master/kandang-groups/services/kandang_group.service.go @@ -70,7 +70,14 @@ func (s kandangGroupService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e if params.PicId != 0 { db = db.Where("kandang_groups.pic_id = ?", params.PicId) } - return db.Order("kandang_groups.created_at DESC").Order("kandang_groups.updated_at DESC") + + if params.OrderBy == "desc" || params.OrderBy == "" { + db = db.Order(fmt.Sprintf("kandang_groups.%s DESC", params.SortBy)) + } else { + db = db.Order(fmt.Sprintf("kandang_groups.%s ASC", params.SortBy)) + } + + return db }) if scopeErr != nil { diff --git a/internal/modules/master/kandang-groups/validations/kandang_group.validation.go b/internal/modules/master/kandang-groups/validations/kandang_group.validation.go index 9637ebe7..b1edd49a 100644 --- a/internal/modules/master/kandang-groups/validations/kandang_group.validation.go +++ b/internal/modules/master/kandang-groups/validations/kandang_group.validation.go @@ -20,4 +20,6 @@ type Query struct { Search string `query:"search" validate:"omitempty,max=50"` LocationId int `query:"location_id" validate:"omitempty,number,gt=0"` PicId int `query:"pic_id" validate:"omitempty,number,gt=0"` + SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"` + OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"` } diff --git a/internal/modules/master/kandangs/controllers/kandang.controller.go b/internal/modules/master/kandangs/controllers/kandang.controller.go index b1d016df..98f995b8 100644 --- a/internal/modules/master/kandangs/controllers/kandang.controller.go +++ b/internal/modules/master/kandangs/controllers/kandang.controller.go @@ -29,6 +29,8 @@ func (u *KandangController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), LocationId: c.QueryInt("location_id", 0), PicId: c.QueryInt("pic_id", 0), + OrderBy: c.Query("order_by", "desc"), + SortBy: c.Query("sort_by", "created_at"), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index fcb39011..27fbc8bb 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -66,7 +66,14 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity if params.PicId != 0 { db = db.Where("pic_id = ?", params.PicId) } - return db.Order("created_at DESC").Order("updated_at DESC") + + if params.OrderBy == "desc" || params.OrderBy == "" { + db = db.Order(fmt.Sprintf("%s DESC", params.SortBy)) + } else { + db = db.Order(fmt.Sprintf("%s ASC", params.SortBy)) + } + + return db }) if scopeErr != nil { diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index 9d5fe103..de82bf48 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -26,4 +26,6 @@ type Query struct { Search string `query:"search" validate:"omitempty,max=50"` LocationId int `query:"location_id" validate:"omitempty,number,gt=0"` PicId int `query:"pic_id" validate:"omitempty,number,gt=0"` + SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"` + OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"` } From 19d7cd33ca029a47f88bf0749446856779e3692f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 4 May 2026 16:27:50 +0700 Subject: [PATCH 05/11] fix: add search for Kandang Kosong --- .../daily-checklists/services/daily-checklist.service.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 8e60ef8b..fdbe4c8b 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -277,7 +277,11 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([ normalizedSearch := re.ReplaceAllString(params.Search, "") if normalizedSearch != "" { like := "%" + normalizedSearch + "%" - db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", like, like) + db = db.Where(`( + regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR + regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR + (dc.category = 'empty_kandang' AND regexp_replace('Kandang Kosong', '[^a-zA-Z0-9]', '', 'g') ILIKE ?) + )`, like, like, like) } } From 48351661c590d97f85cd6f5729d7b47853b49cb5 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 4 May 2026 16:28:03 +0700 Subject: [PATCH 06/11] fix: add order_by and sort_by query to master data employee --- .../controllers/employees.controller.go | 8 +++++--- .../employees/services/employees.service.go | 16 ++++++++++++---- .../validations/employees.validation.go | 2 ++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/internal/modules/master/employees/controllers/employees.controller.go b/internal/modules/master/employees/controllers/employees.controller.go index 3d0901c8..9b633803 100644 --- a/internal/modules/master/employees/controllers/employees.controller.go +++ b/internal/modules/master/employees/controllers/employees.controller.go @@ -24,9 +24,11 @@ func NewEmployeesController(employeesService service.EmployeesService) *Employee func (u *EmployeesController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + OrderBy: c.Query("order_by", "desc"), + SortBy: c.Query("sort_by", "updated_at"), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/employees/services/employees.service.go b/internal/modules/master/employees/services/employees.service.go index a7a6cef3..a1f02c96 100644 --- a/internal/modules/master/employees/services/employees.service.go +++ b/internal/modules/master/employees/services/employees.service.go @@ -2,6 +2,7 @@ package service import ( "errors" + "fmt" "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -126,11 +127,18 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if params.IsActive != nil { db = db.Where("employees.is_active = ?", *params.IsActive) } - return db. + + db = db. Select("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at"). - Group("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at"). - Order("employees.created_at DESC"). - Order("employees.updated_at DESC") + Group("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at") + + if params.OrderBy == "desc" || params.OrderBy == "" { + db = db.Order(fmt.Sprintf("employees.%s DESC", params.SortBy)) + } else { + db = db.Order(fmt.Sprintf("employees.%s ASC", params.SortBy)) + } + + return db }) if err != nil { diff --git a/internal/modules/master/employees/validations/employees.validation.go b/internal/modules/master/employees/validations/employees.validation.go index 83608071..09aa471e 100644 --- a/internal/modules/master/employees/validations/employees.validation.go +++ b/internal/modules/master/employees/validations/employees.validation.go @@ -18,4 +18,6 @@ type Query struct { Search string `query:"search" validate:"omitempty,max=50"` KandangId *uint `query:"kandang_id" validate:"omitempty"` IsActive *bool `query:"is_active" validate:"omitempty"` + SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"` + OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"` } From f9de4d28f922026d2ff93d3ab4ab738636e1deee Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 4 May 2026 23:06:13 +0700 Subject: [PATCH 07/11] fixing stock log when editing recording --- .../recordings/services/recording.service.go | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5264e32a..3f0e7946 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -3121,6 +3121,12 @@ func (s *recordingService) reflowSyncRecordingStocks( existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) } + shouldWriteLog := shouldWriteRecordingStockLog(note, actorID) + if shouldWriteLog && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + resetLogState := newRecordingStockLogState() + stocksToApply := make([]entity.RecordingStock, 0, len(incoming)) for _, item := range incoming { list := existingByWarehouse[item.ProductWarehouseId] @@ -3128,6 +3134,25 @@ func (s *recordingService) reflowSyncRecordingStocks( if len(list) > 0 { stock = list[0] existingByWarehouse[item.ProductWarehouseId] = list[1:] + + // Write reset (increase) stock_log for the OLD consumption BEFORE overwriting UsageQty. + // FIFO internally does Rollback+Reallocate inside reflowApplyRecordingStocks, but the + // corresponding +increase stock_log for the rollback step was previously missing, causing + // stock_log.stock to drift below the true FIFO qty on every in-place edit. + rollbackQty := recordingStockRollbackQty(stock) + if rollbackQty > 1e-6 && shouldWriteLog { + resetLog := &entity.StockLog{ + ProductWarehouseId: stock.ProductWarehouseId, + CreatedBy: actorID, + Increase: rollbackQty, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: stock.RecordingId, + Notes: note, + } + if err := s.appendRecordingStockLog(ctx, tx, resetLogState, resetLog); err != nil { + return err + } + } } else { zero := 0.0 stock = entity.RecordingStock{ From 7bab8c66c1ed15403519993270c95c5499902102 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 5 May 2026 06:41:10 +0700 Subject: [PATCH 08/11] add gudang tujuan to po --- .../purchases/controllers/purchase.export.go | 189 ++++++++++-------- .../modules/purchases/dto/purchase.dto.go | 2 + 2 files changed, 108 insertions(+), 83 deletions(-) diff --git a/internal/modules/purchases/controllers/purchase.export.go b/internal/modules/purchases/controllers/purchase.export.go index cf8ede38..046291df 100644 --- a/internal/modules/purchases/controllers/purchase.export.go +++ b/internal/modules/purchases/controllers/purchase.export.go @@ -3,13 +3,11 @@ package controller import ( "fmt" "math" - "sort" "strconv" "strings" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto" "github.com/gofiber/fiber/v2" "github.com/xuri/excelize/v2" @@ -45,7 +43,6 @@ func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) { } } - listItems := dto.ToPurchaseListDTOs(purchases) grandTotals := buildPurchaseGrandTotalMap(purchases) if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil { @@ -54,7 +51,7 @@ func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) { if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil { return nil, err } - if err := setPurchaseExportRows(file, purchaseExportSheetName, listItems, grandTotals); err != nil { + if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases, grandTotals); err != nil { return nil, err } if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{ @@ -81,10 +78,11 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error { "D": 14, "E": 22, "F": 22, - "G": 18, - "H": 18, - "I": 52, - "J": 24, + "G": 22, + "H": 32, + "I": 18, + "J": 18, + "K": 24, } for col, width := range columnWidths { @@ -107,9 +105,10 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error { "Tanggal Terima", "Supplier", "Lokasi", + "Gudang", + "Product", "Status", "Grand Total", - "Products", "Notes", } @@ -138,49 +137,34 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error { return err } - return file.SetCellStyle(sheet, "A1", "J1", headerStyle) + return file.SetCellStyle(sheet, "A1", "K1", headerStyle) } -func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.PurchaseListDTO, grandTotals map[uint]float64) error { - if len(items) == 0 { +func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase, grandTotals map[uint]float64) error { + if len(purchases) == 0 { return nil } - for i, item := range items { - row := strconv.Itoa(i + 2) - if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(item.PrNumber)); err != nil { - return err + rowIdx := 2 + for p := range purchases { + purchase := &purchases[p] + total := grandTotals[purchase.Id] + if len(purchase.Items) == 0 { + if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, total); err != nil { + return err + } + rowIdx++ + continue } - if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(item.PoNumber)); err != nil { - return err - } - if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(item.PoDate)); err != nil { - return err - } - if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil { - return err - } - if err := file.SetCellValue(sheet, "E"+row, safePurchaseSupplierName(item)); err != nil { - return err - } - if err := file.SetCellValue(sheet, "F"+row, safePurchaseLocationName(item)); err != nil { - return err - } - if err := file.SetCellValue(sheet, "G"+row, formatPurchaseExportStatus(item)); err != nil { - return err - } - if err := file.SetCellValue(sheet, "H"+row, formatPurchaseRupiah(grandTotals[item.Id])); err != nil { - return err - } - if err := file.SetCellValue(sheet, "I"+row, formatPurchaseProducts(item)); err != nil { - return err - } - if err := file.SetCellValue(sheet, "J"+row, safePurchaseExportPointerText(item.Notes)); err != nil { - return err + for it := range purchase.Items { + if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], total); err != nil { + return err + } + rowIdx++ } } - lastRow := len(items) + 1 + lastRow := rowIdx - 1 dataStyle, err := file.NewStyle(&excelize.Style{ Alignment: &excelize.Alignment{ Horizontal: "left", @@ -197,7 +181,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha if err != nil { return err } - if err := file.SetCellStyle(sheet, "A2", "J"+strconv.Itoa(lastRow), dataStyle); err != nil { + if err := file.SetCellStyle(sheet, "A2", "K"+strconv.Itoa(lastRow), dataStyle); err != nil { return err } @@ -217,7 +201,59 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha return err } - return file.SetCellStyle(sheet, "H2", "H"+strconv.Itoa(lastRow), moneyStyle) + return file.SetCellStyle(sheet, "J2", "J"+strconv.Itoa(lastRow), moneyStyle) +} + +func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, grandTotal float64) error { + row := strconv.Itoa(rowIdx) + + // Purchase-level columns (repeat across rows of the same purchase) + if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(purchase.PrNumber)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(purchase.PoNumber)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(purchase.PoDate)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "E"+row, safePurchaseExportEntitySupplierName(purchase)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "I"+row, formatPurchaseExportEntityStatus(purchase)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "J"+row, formatPurchaseRupiah(grandTotal)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "K"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil { + return err + } + + // Item-level columns + if item == nil { + for _, col := range []string{"D", "F", "G", "H"} { + if err := file.SetCellValue(sheet, col+row, "-"); err != nil { + return err + } + } + return nil + } + + if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "F"+row, safePurchaseItemLocationName(item)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "G"+row, safePurchaseWarehouseName(item)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "H"+row, safePurchaseItemProductName(item)); err != nil { + return err + } + + return nil } func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 { @@ -232,31 +268,45 @@ func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 { return result } -func safePurchaseSupplierName(item dto.PurchaseListDTO) string { - if item.Supplier == nil { +func safePurchaseExportEntitySupplierName(purchase *entity.Purchase) string { + if purchase.Supplier.Id == 0 { return "-" } - return safePurchaseExportText(item.Supplier.Name) + return safePurchaseExportText(purchase.Supplier.Name) } -func safePurchaseLocationName(item dto.PurchaseListDTO) string { - if item.Location == nil { +func safePurchaseWarehouseName(item *entity.PurchaseItem) string { + if item.Warehouse == nil { return "-" } - return safePurchaseExportText(item.Location.Name) + return safePurchaseExportText(item.Warehouse.Name) } -func formatPurchaseExportStatus(item dto.PurchaseListDTO) string { - if item.LatestApproval == nil { +func safePurchaseItemLocationName(item *entity.PurchaseItem) string { + if item.Warehouse == nil || item.Warehouse.Location == nil { + return "-" + } + return safePurchaseExportText(item.Warehouse.Location.Name) +} + +func safePurchaseItemProductName(item *entity.PurchaseItem) string { + if item.Product == nil { + return "-" + } + return safePurchaseExportText(item.Product.Name) +} + +func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string { + if purchase.LatestApproval == nil { return "-" } - if item.LatestApproval.Action != nil && - strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) { + if purchase.LatestApproval.Action != nil && + strings.EqualFold(strings.TrimSpace(string(*purchase.LatestApproval.Action)), string(entity.ApprovalActionRejected)) { return "Ditolak" } - return safePurchaseExportText(item.LatestApproval.StepName) + return safePurchaseExportText(purchase.LatestApproval.StepName) } func formatPurchaseExportDate(value *time.Time) string { @@ -273,33 +323,6 @@ func formatPurchaseExportDate(value *time.Time) string { return t.Format("02-01-2006") } -func formatPurchaseProducts(item dto.PurchaseListDTO) string { - if len(item.Products) == 0 { - return "-" - } - - seen := make(map[string]struct{}) - names := make([]string, 0, len(item.Products)) - for i := range item.Products { - name := strings.TrimSpace(item.Products[i].Name) - if name == "" { - continue - } - if _, exists := seen[name]; exists { - continue - } - seen[name] = struct{}{} - names = append(names, name) - } - - if len(names) == 0 { - return "-" - } - - sort.Strings(names) - return strings.Join(names, ", ") -} - func safePurchaseExportPointerText(value *string) string { if value == nil { return "-" diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index 22db3d7d..fd1c859a 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -31,6 +31,7 @@ type PurchaseListDTO struct { CreatedUser *userDTO.UserRelationDTO `json:"created_user"` RequesterName string `json:"requester_name"` PoExpedition []PoExpeditionDTO `json:"po_expedition"` + Items []PurchaseItemDTO `json:"items"` Products []productDTO.ProductRelationDTO `json:"products"` Location *locationDTO.LocationRelationDTO `json:"location"` Area *areaDTO.AreaRelationDTO `json:"area"` @@ -227,6 +228,7 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO { CreatedUser: createdUser, RequesterName: requesterName, PoExpedition: poExpedition, + Items: ToPurchaseItemDTOs(p.Items), Products: products, Location: location, Area: area, From 49ea3f02958fb71dedbe4e808489b544a6ad471f Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 5 May 2026 10:06:35 +0700 Subject: [PATCH 09/11] add command for fix stock log missmatch --- cmd/fix-stock-log-drift/main.go | 387 ++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 cmd/fix-stock-log-drift/main.go diff --git a/cmd/fix-stock-log-drift/main.go b/cmd/fix-stock-log-drift/main.go new file mode 100644 index 00000000..fc06de3c --- /dev/null +++ b/cmd/fix-stock-log-drift/main.go @@ -0,0 +1,387 @@ +// Command: fix-stock-log-drift +// +// Tujuan: +// Sinkronkan `stock_logs.stock` (running ledger) dengan `product_warehouses.qty` +// (FIFO truth) ketika keduanya drift. +// +// Drift biasanya terjadi karena bug di Recording-Edit (sebelum fix) yang +// hanya menulis -decrease tanpa +increase saat in-place update. Akibatnya +// running ledger di stock_logs tertinggal dari qty riil di product_warehouses. +// +// Cara kerja: +// 1. Ambil product_warehouses.qty (sebagai truth) +// 2. Ambil last_stock_log.stock +// 3. Cari recording yang berkontribusi pada drift (untuk notes) +// 4. Hitung drift = qty - last_stock_log.stock +// 5. Jika drift != 0, insert 1 stock_log corrective: +// - drift > 0 → increase = drift +// - drift < 0 → decrease = |drift| +// stock akhir akan sama dengan qty (truth). +// Notes otomatis berisi daftar recording IDs yang berkontribusi pada drift. +// +// Mode: +// --apply=false (default) → dry-run, hanya tampilkan rencana +// --apply=true → eksekusi insert +// +// Contoh: +// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 +// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply +// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply \ +// --actor-id=1 --notes="Koreksi manual drift" + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "math" + "os" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +const ( + qtyEpsilon = 1e-6 + defaultActorID uint = 1 + maxSuspectInNotes = 30 +) + +type driftRow struct { + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + WarehouseName string `gorm:"column:warehouse_name"` + CurrentQty float64 `gorm:"column:current_qty"` + LastLogStock float64 `gorm:"column:last_log_stock"` + LastLogID uint `gorm:"column:last_log_id"` + FifoExpected float64 `gorm:"column:fifo_expected"` +} + +type suspectRecording struct { + RecordingID uint `gorm:"column:recording_id"` + FifoUsage float64 `gorm:"column:fifo_usage"` + NetLogConsumed float64 `gorm:"column:net_log_consumed"` + Phantom float64 `gorm:"column:phantom"` +} + +func main() { + var ( + productWarehouseID uint + apply bool + actorID uint + notes string + ) + + flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Target product_warehouse_id (required)") + flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run") + flag.UintVar(&actorID, "actor-id", defaultActorID, "User id yang akan dicatat sebagai created_by stock_log corrective") + flag.StringVar(¬es, "notes", "", "Custom notes untuk stock_log corrective (opsional, default=auto-generate dari data recording)") + flag.Parse() + + notes = strings.TrimSpace(notes) + if err := validateFlags(productWarehouseID, actorID); err != nil { + log.Fatalf("invalid flags: %v", err) + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + + row, err := loadDriftRow(ctx, db, productWarehouseID) + if err != nil { + log.Fatalf("failed to load product warehouse: %v", err) + } + + suspects, err := loadSuspectRecordings(ctx, db, productWarehouseID) + if err != nil { + log.Fatalf("failed to load suspect recordings: %v", err) + } + + drift := row.CurrentQty - row.LastLogStock + + // Print info header + fmt.Printf("Mode: %s\n", modeLabel(apply)) + fmt.Printf("Target product_warehouse_id: %d\n", productWarehouseID) + fmt.Printf("Product: %q\n", row.ProductName) + fmt.Printf("Warehouse: %q\n", row.WarehouseName) + fmt.Printf("Current qty (product_warehouses): %.3f\n", row.CurrentQty) + fmt.Printf("FIFO expected (sum total_qty - total_used): %.3f\n", row.FifoExpected) + fmt.Printf("Last stock_log: id=%d stock=%.3f\n", row.LastLogID, row.LastLogStock) + fmt.Printf("Drift (qty - last_log_stock): %+.3f\n", drift) + + if !nearlyEqual(row.CurrentQty, row.FifoExpected) { + fmt.Println() + fmt.Println("⚠️ WARNING: product_warehouses.qty TIDAK match dengan FIFO expected.") + fmt.Println(" Disarankan jalankan dulu cmd/reflow-quantity-product-warehouse-from-stock-allocation") + fmt.Println(" sebelum fix stock_log drift, agar truth source-nya sudah benar.") + } + + // Print suspect recordings + fmt.Println() + if len(suspects) > 0 { + totalPhantom := 0.0 + for _, s := range suspects { + totalPhantom += s.Phantom + } + fmt.Printf("Suspect recordings (drift contributors): %d\n", len(suspects)) + for _, s := range suspects { + fmt.Printf( + " #%-6d fifo=%-10.3f net_log=%-10.3f phantom=%+.3f\n", + s.RecordingID, s.FifoUsage, s.NetLogConsumed, s.Phantom, + ) + } + fmt.Printf("Total suspect phantom: %+.3f\n", totalPhantom) + } else { + fmt.Println("Suspect recordings: none found (drift origin unknown)") + } + fmt.Println() + + if nearlyEqual(drift, 0) { + fmt.Println("✓ Tidak ada drift. Stock_log sudah sinkron dengan product_warehouses.qty.") + fmt.Println("Summary: planned=0 inserted=0 skipped=1 failed=0") + return + } + + // Build notes if not provided + if notes == "" { + notes = buildDefaultNotes(row, drift, suspects) + } + + plan := buildCorrectiveLog(row, drift, actorID, notes) + + fmt.Printf( + "PLAN insert stock_log:\n pw=%d increase=%.3f decrease=%.3f stock=%.3f\n notes=%q\n", + plan.ProductWarehouseId, + plan.Increase, + plan.Decrease, + plan.Stock, + plan.Notes, + ) + + if !apply { + fmt.Println() + fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=0 (dry-run)") + return + } + + if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Re-check di dalam transaction agar aman dari race condition + current, err := loadDriftRow(ctx, tx, productWarehouseID) + if err != nil { + return fmt.Errorf("re-read product_warehouse_id=%d: %w", productWarehouseID, err) + } + currentDrift := current.CurrentQty - current.LastLogStock + if nearlyEqual(currentDrift, 0) { + fmt.Println("Drift hilang sebelum insert (kemungkinan ada operasi paralel). Skip.") + return nil + } + fresh := buildCorrectiveLog(current, currentDrift, actorID, notes) + if err := tx.Create(&fresh).Error; err != nil { + return fmt.Errorf("insert corrective stock_log for pw=%d: %w", productWarehouseID, err) + } + fmt.Printf( + "DONE inserted stock_log id=%d pw=%d increase=%.3f decrease=%.3f stock=%.3f\n", + fresh.Id, + fresh.ProductWarehouseId, + fresh.Increase, + fresh.Decrease, + fresh.Stock, + ) + return nil + }); err != nil { + fmt.Println() + fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=1") + log.Printf("error: %v", err) + os.Exit(1) + } + + fmt.Println() + fmt.Println("Summary: planned=1 inserted=1 skipped=0 failed=0") +} + +func validateFlags(productWarehouseID uint, actorID uint) error { + if productWarehouseID == 0 { + return errors.New("--product-warehouse-id is required (must be > 0)") + } + if actorID == 0 { + return errors.New("--actor-id must be > 0") + } + return nil +} + +func loadDriftRow(ctx context.Context, db *gorm.DB, productWarehouseID uint) (driftRow, error) { + row := driftRow{} + + lastLogSub := db.WithContext(ctx). + Table("stock_logs"). + Select("id, product_warehouse_id, stock"). + Where("product_warehouse_id = ?", productWarehouseID). + Order("id DESC"). + Limit(1) + + fifoSub := db.WithContext(ctx). + Table("purchase_items"). + Select(` + product_warehouse_id, + COALESCE(SUM(COALESCE(total_qty, 0) - COALESCE(total_used, 0)), 0) AS fifo_expected + `). + Where("product_warehouse_id = ?", productWarehouseID). + Group("product_warehouse_id") + + if err := db.WithContext(ctx). + Table("product_warehouses pw"). + Select(` + pw.id AS product_warehouse_id, + pw.product_id AS product_id, + COALESCE(p.name, '') AS product_name, + COALESCE(w.name, '') AS warehouse_name, + COALESCE(pw.qty, 0) AS current_qty, + COALESCE(last_log.stock, 0) AS last_log_stock, + COALESCE(last_log.id, 0) AS last_log_id, + COALESCE(fifo.fifo_expected, 0) AS fifo_expected + `). + Joins("LEFT JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("LEFT JOIN (?) last_log ON last_log.product_warehouse_id = pw.id", lastLogSub). + Joins("LEFT JOIN (?) fifo ON fifo.product_warehouse_id = pw.id", fifoSub). + Where("pw.id = ?", productWarehouseID). + Scan(&row).Error; err != nil { + return row, err + } + + if row.ProductWarehouseID == 0 { + return row, fmt.Errorf("product_warehouse_id=%d not found", productWarehouseID) + } + + return row, nil +} + +// loadSuspectRecordings mencari recording yang net stock_log consumed-nya +// melebihi FIFO usage_qty — ini adalah recording yang berkontribusi pada drift +// akibat bug Recording-Edit yang hanya menulis -decrease tanpa +increase. +func loadSuspectRecordings(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]suspectRecording, error) { + rows := make([]suspectRecording, 0) + if err := db.WithContext(ctx). + Table("recording_stocks rs"). + Select(` + rs.recording_id, + COALESCE(rs.usage_qty, 0) AS fifo_usage, + ( + COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) - + COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) + ) AS net_log_consumed, + ( + COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) - + COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) - + COALESCE(rs.usage_qty, 0) + ) AS phantom + `). + Joins(` + JOIN stock_logs sl ON sl.loggable_type = ? + AND sl.loggable_id = rs.recording_id + AND sl.product_warehouse_id = rs.product_warehouse_id + `, string(utils.StockLogTypeRecording)). + Where("rs.product_warehouse_id = ?", productWarehouseID). + Group("rs.recording_id, rs.usage_qty"). + Having(` + ABS( + COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) - + COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) - + COALESCE(rs.usage_qty, 0) + ) > ? + `, qtyEpsilon). + Order("rs.recording_id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// buildDefaultNotes membuat notes otomatis yang berisi penjelasan drift +// beserta daftar recording_id yang berkontribusi + phantom amount masing-masing. +func buildDefaultNotes(row driftRow, drift float64, suspects []suspectRecording) string { + sign := "+" + if drift < 0 { + sign = "" + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf( + "Koreksi drift stock_log akibat bug Recording-Edit (in-place update menulis -decrease tanpa +increase). PW=%d (%s) drift=%s%.3f.", + row.ProductWarehouseID, + row.WarehouseName, + sign, + drift, + )) + + if len(suspects) == 0 { + return sb.String() + } + + sb.WriteString(" Recordings affected:") + + limit := len(suspects) + truncated := 0 + if limit > maxSuspectInNotes { + truncated = limit - maxSuspectInNotes + limit = maxSuspectInNotes + } + + for i := 0; i < limit; i++ { + s := suspects[i] + phantomSign := "+" + if s.Phantom < 0 { + phantomSign = "" + } + sb.WriteString(fmt.Sprintf(" #%d(%s%.0f)", s.RecordingID, phantomSign, s.Phantom)) + if i < limit-1 || truncated > 0 { + sb.WriteString(",") + } + } + + if truncated > 0 { + sb.WriteString(fmt.Sprintf(" ... (+%d more)", truncated)) + } + + sb.WriteString(".") + return sb.String() +} + +func buildCorrectiveLog(row driftRow, drift float64, actorID uint, notes string) entity.StockLog { + corrective := entity.StockLog{ + ProductWarehouseId: row.ProductWarehouseID, + CreatedBy: actorID, + LoggableType: string(utils.StockLogTypeAdjustment), + LoggableId: 0, + Stock: row.CurrentQty, + Notes: notes, + CreatedAt: time.Now(), + } + if drift > 0 { + corrective.Increase = drift + corrective.Decrease = 0 + } else { + corrective.Increase = 0 + corrective.Decrease = -drift + } + return corrective +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func nearlyEqual(a, b float64) bool { + return math.Abs(a-b) <= qtyEpsilon +} From d07f074fb1eb036f835c2c815f46d775c66be6e9 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Tue, 5 May 2026 12:09:02 +0700 Subject: [PATCH 10/11] fix(migrate): align recording day constraint with zero-based migration --- ...260504035824_adjust_recording_day_zero_indexed.down.sql | 7 +++++++ ...20260504035824_adjust_recording_day_zero_indexed.up.sql | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.down.sql b/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.down.sql index 2f92644d..fccead4d 100644 --- a/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.down.sql +++ b/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.down.sql @@ -12,3 +12,10 @@ WHERE r.deleted_at IS NULL WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id AND pc.deleted_at IS NULL ) IS NOT NULL; + +ALTER TABLE recordings +DROP CONSTRAINT IF EXISTS chk_recordings_day; + +ALTER TABLE recordings +ADD CONSTRAINT chk_recordings_day +CHECK (day IS NULL OR day >= 1); diff --git a/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.up.sql b/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.up.sql index 67937750..a5490f53 100644 --- a/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.up.sql +++ b/internal/database/migrations/20260504035824_adjust_recording_day_zero_indexed.up.sql @@ -1,3 +1,10 @@ +ALTER TABLE recordings +DROP CONSTRAINT IF EXISTS chk_recordings_day; + +ALTER TABLE recordings +ADD CONSTRAINT chk_recordings_day +CHECK (day IS NULL OR day >= 0); + UPDATE recordings r SET day = ( SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int From 6f6985ef32cc16202400a6fcc08446938d79f857 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Tue, 5 May 2026 13:32:16 +0700 Subject: [PATCH 11/11] ci: ignore partial aws env during ecr login --- .gitlab-ci.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 18924ce3..4af6f94c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -27,10 +27,24 @@ workflow: .ecr_login: &ecr_login | AWS_CLI_ENV_ARGS="" AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION" - AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}" - AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}" - if [ -n "${AWS_SESSION_TOKEN:-}" ]; then - AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" + + HAS_ACCESS_KEY="false" + HAS_SECRET_KEY="false" + if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then + HAS_ACCESS_KEY="true" + fi + if [ -n "${AWS_SECRET_ACCESS_KEY:-}" ]; then + HAS_SECRET_KEY="true" + fi + + if [ "$HAS_ACCESS_KEY" = "true" ] && [ "$HAS_SECRET_KEY" = "true" ]; then + AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" + AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" + if [ -n "${AWS_SESSION_TOKEN:-}" ]; then + AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" + fi + elif [ "$HAS_ACCESS_KEY" = "true" ] || [ "$HAS_SECRET_KEY" = "true" ] || [ -n "${AWS_SESSION_TOKEN:-}" ]; then + echo "WARN: Incomplete AWS_* env vars detected; ignoring injected AWS credentials for ECR login." fi PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \