diff --git a/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..down.sql b/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..down.sql new file mode 100644 index 00000000..48e4ff1a --- /dev/null +++ b/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..down.sql @@ -0,0 +1,14 @@ +-- Reverse UPSERT: hapus baris PFK 47 & 48 yang kemungkinan baru diinsert oleh up migration ini. +-- Jika sebelumnya sudah ada (ON CONFLICT DO UPDATE), baris ini akan terhapus — +-- restore manual dari backup jika diperlukan. +DELETE FROM farm_depreciation_manual_inputs +WHERE project_flock_id IN (47, 48); + +-- UPDATE rows untuk PFK 4–27 tidak bisa di-reverse secara presisi: +-- nilai total_cost sebelum migration ini tidak tersimpan di migration history +-- (data awal di-load via cmd/import-farm-depreciation-manual-inputs dari Excel). +-- PFK 10 dan 11 tidak berubah (nilai sama dengan state dari migration 20260529144559). +-- Jika perlu rollback penuh: restore dari database backup atau re-import Excel lama. + +-- Recompute snapshots setelah rollback +TRUNCATE TABLE farm_depreciation_snapshots; diff --git a/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..up.sql b/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..up.sql new file mode 100644 index 00000000..24a72a2e --- /dev/null +++ b/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..up.sql @@ -0,0 +1,105 @@ + + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 1900157533.55, + cutover_date = DATE '2026-02-28', + updated_at = NOW() +WHERE project_flock_id = 10; + + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 146658321.066, + cutover_date = DATE '2026-02-28', + updated_at = NOW() +WHERE project_flock_id = 13; + + + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 51824694.138, + cutover_date = DATE '2026-02-28', + updated_at = NOW() +WHERE project_flock_id = 17; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 15491774.796, + cutover_date = DATE '2026-02-28', + updated_at = NOW() +WHERE project_flock_id = 8; + + + + +-- Cutover 2026-02-28 (lanjutan) +UPDATE farm_depreciation_manual_inputs +SET total_cost = 575074391.36, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 4; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 578360642.51, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 5; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 880983605.92, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 6; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 391669576.153, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 9; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 2521797832.14, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 11; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 139227054.164, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 12; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 380083106.836, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 14; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 705136853.847, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 15; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 209816474.000, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 18; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 557606867.000, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 19; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 239330456.11, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 20; + +UPDATE farm_depreciation_manual_inputs +SET total_cost = 4724203916.72, cutover_date = DATE '2026-02-28', updated_at = NOW() +WHERE project_flock_id = 26; + +-- Cutover 2026-05-15 +UPDATE farm_depreciation_manual_inputs +SET total_cost = 5449963647.43, cutover_date = DATE '2026-05-15', updated_at = NOW() +WHERE project_flock_id = 27; + +-- Cutover 2026-06-08 (upsert — row mungkin belum ada) +INSERT INTO farm_depreciation_manual_inputs (project_flock_id, total_cost, cutover_date, created_at, updated_at) +VALUES (47, 5395429899.42, DATE '2026-06-08', NOW(), NOW()) +ON CONFLICT (project_flock_id) DO UPDATE + SET total_cost = EXCLUDED.total_cost, + cutover_date = EXCLUDED.cutover_date, + updated_at = NOW(); + +-- Cutover 2026-06-16 (upsert — row mungkin belum ada) +INSERT INTO farm_depreciation_manual_inputs (project_flock_id, total_cost, cutover_date, created_at, updated_at) +VALUES (48, 5514616442.08, DATE '2026-06-16', NOW(), NOW()) +ON CONFLICT (project_flock_id) DO UPDATE + SET total_cost = EXCLUDED.total_cost, + cutover_date = EXCLUDED.cutover_date, + updated_at = NOW(); + +-- Pengaman: pastikan snapshot di-recompute dengan total_cost baru +-- saat user request /api/reports/expense/depreciation +TRUNCATE TABLE farm_depreciation_snapshots; diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index b2128e88..29921d6a 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -387,35 +387,87 @@ func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, stan return nil } - week := ((day - 1) / 7) + 1 - if week <= 0 { + requestedWeek := ((day - 1) / 7) + 1 + if requestedWeek <= 0 { return nil } upperCategory := strings.ToUpper(category) if upperCategory == string(utils.ProjectFlockCategoryLaying) { - detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + effectiveWeek := requestedWeek + firstCommonWeek, ok, err := s.layingFirstCommonStandardWeek(ctx, standardID) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) - } return err } - if detail == nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + if ok && requestedWeek < firstCommonWeek { + effectiveWeek = firstCommonWeek } + + detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, effectiveWeek) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, effectiveWeek) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + if detail != nil && growthDetail != nil { + return nil + } + + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek)) } - growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, requestedWeek) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek)) } return err } if growthDetail == nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek)) } return nil } + +func (s productionStandardService) layingFirstCommonStandardWeek(ctx context.Context, standardID uint) (int, bool, error) { + details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + return 0, false, err + } + detailWeeks := make(map[int]struct{}, len(details)) + for _, detail := range details { + if detail.Week <= 0 { + continue + } + detailWeeks[detail.Week] = struct{}{} + } + + growthDetails, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + return 0, false, err + } + + firstCommonWeek := 0 + for _, detail := range growthDetails { + if detail.Week <= 0 { + continue + } + if _, ok := detailWeeks[detail.Week]; !ok { + continue + } + if firstCommonWeek == 0 || detail.Week < firstCommonWeek { + firstCommonWeek = detail.Week + } + } + + return firstCommonWeek, firstCommonWeek > 0, nil +} diff --git a/internal/modules/master/production-standards/services/production-standard.service_test.go b/internal/modules/master/production-standards/services/production-standard.service_test.go new file mode 100644 index 00000000..7915ab40 --- /dev/null +++ b/internal/modules/master/production-standards/services/production-standard.service_test.go @@ -0,0 +1,95 @@ +package service + +import ( + "context" + "strings" + "testing" + + "github.com/glebarez/sqlite" + repositories "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +func TestEnsureWeekAvailableAllowsLayingBeforeFirstCommonStandardWeek(t *testing.T) { + svc := setupProductionStandardServiceTest(t) + + if err := svc.EnsureWeekAvailable(context.Background(), 1, string(utils.ProjectFlockCategoryLaying), 85); err != nil { + t.Fatalf("expected pre-standard laying week to be allowed, got %v", err) + } +} + +func TestEnsureWeekAvailableRejectsLayingMissingWeekAfterStandardStarts(t *testing.T) { + svc := setupProductionStandardServiceTest(t) + + err := svc.EnsureWeekAvailable(context.Background(), 1, string(utils.ProjectFlockCategoryLaying), 127) + if err == nil { + t.Fatal("expected missing laying standard week to be rejected") + } + if !strings.Contains(err.Error(), "week 19") { + t.Fatalf("expected error to mention requested week 19, got %v", err) + } +} + +func TestEnsureWeekAvailableKeepsGrowingWeekStrict(t *testing.T) { + svc := setupProductionStandardServiceTest(t) + + err := svc.EnsureWeekAvailable(context.Background(), 2, string(utils.ProjectFlockCategoryGrowing), 8) + if err == nil { + t.Fatal("expected missing growing standard week to be rejected") + } + if !strings.Contains(err.Error(), "week 2") { + t.Fatalf("expected error to mention requested week 2, got %v", err) + } +} + +func setupProductionStandardServiceTest(t *testing.T) productionStandardService { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + statements := []string{ + `CREATE TABLE production_standard_details ( + id INTEGER PRIMARY KEY, + production_standard_id INTEGER NOT NULL, + week INTEGER NOT NULL, + target_hen_day_production NUMERIC NULL, + target_hen_house_production NUMERIC NULL, + target_egg_weight NUMERIC NULL, + target_egg_mass NUMERIC NULL, + standard_fcr NUMERIC NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL + )`, + `CREATE TABLE standard_growth_details ( + id INTEGER PRIMARY KEY, + production_standard_id INTEGER NOT NULL, + target_mean_bw NUMERIC NULL, + max_depletion NUMERIC NULL, + min_uniformity NUMERIC NOT NULL, + week INTEGER NOT NULL, + feed_intake NUMERIC NULL, + created_at TIMESTAMP NULL, + created_by INTEGER NOT NULL + )`, + `INSERT INTO production_standard_details (id, production_standard_id, week, standard_fcr) VALUES + (1, 1, 18, 2.1)`, + `INSERT INTO standard_growth_details (id, production_standard_id, week, min_uniformity, created_by) VALUES + (1, 1, 18, 80, 1), + (2, 2, 1, 80, 1)`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return productionStandardService{ + ProductionStandardDetailRepo: repositories.NewProductionStandardDetailRepository(db), + StandardGrowthDetailRepo: repositories.NewStandardGrowthDetailRepository(db), + } +} diff --git a/internal/utils/recording/recording_helpers.go b/internal/utils/recording/recording_helpers.go index fda16f48..ac5c60f3 100644 --- a/internal/utils/recording/recording_helpers.go +++ b/internal/utils/recording/recording_helpers.go @@ -205,6 +205,7 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs)) growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs)) + firstCommonWeekByStd := make(map[uint]int, len(standardIDs)) for standardID := range standardIDs { details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID) @@ -242,6 +243,10 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, growthMap[growth.Week] = &growth } growthDetailByStd[standardID] = growthMap + + if firstCommonWeek, ok := firstCommonStandardWeek(detailMap, growthMap); ok { + firstCommonWeekByStd[standardID] = firstCommonWeek + } } // Batch-load laying transfer targets → EARLIEST source PFK chick_in_date per target. @@ -284,6 +289,9 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, continue } week := computeTransferAwareWeek(item, sourceChickInByTarget) + if firstCommonWeek, ok := firstCommonWeekByStd[standardID]; ok { + week = effectiveProductionStandardWeek(item, week, firstCommonWeek) + } item.StandardWeek = &week cacheKey := standardKey{standardID: standardID, week: week} if cached, ok := cache[cacheKey]; ok { @@ -324,6 +332,38 @@ func applyProductionStandardValues(item *entity.Recording, values productionStan item.StandardFcr = fcr } +func firstCommonStandardWeek( + detailMap map[int]*entity.ProductionStandardDetail, + growthMap map[int]*entity.StandardGrowthDetail, +) (int, bool) { + firstWeek := 0 + for week := range detailMap { + if week <= 0 { + continue + } + if _, ok := growthMap[week]; !ok { + continue + } + if firstWeek == 0 || week < firstWeek { + firstWeek = week + } + } + return firstWeek, firstWeek > 0 +} + +func effectiveProductionStandardWeek(item *entity.Recording, actualWeek int, firstCommonWeek int) int { + if item == nil || actualWeek <= 0 || firstCommonWeek <= 0 { + return actualWeek + } + if !IsLayingRecording(*item) { + return actualWeek + } + if actualWeek < firstCommonWeek { + return firstCommonWeek + } + return actualWeek +} + // collectLayingPFKIDs mengumpulkan semua project_flock_kandang_id dari recording laying func collectLayingPFKIDs(items []*entity.Recording) []uint { seen := make(map[uint]struct{}) diff --git a/internal/utils/recording/util.recording_test.go b/internal/utils/recording/util.recording_test.go index 9ce0ff75..98d5ae17 100644 --- a/internal/utils/recording/util.recording_test.go +++ b/internal/utils/recording/util.recording_test.go @@ -1,10 +1,15 @@ package recording import ( + "context" "testing" + "time" + "github.com/glebarez/sqlite" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" ) func TestMapDepletionsKeepsSourceWarehouseRoutes(t *testing.T) { @@ -45,3 +50,126 @@ func TestMapEggsSetsProjectFlockKandangID(t *testing.T) { t.Fatalf("expected project flock kandang id 44, got %+v", got[0].ProjectFlockKandangId) } } + +func TestAttachProductionStandardsClampsLayingPreStandardWeek(t *testing.T) { + db := setupAttachProductionStandardTestDB(t) + + day := 91 + recordDate := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + chickInDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + recording := &entity.Recording{ + Id: 501, + ProjectFlockKandangId: 103, + RecordDatetime: recordDate, + Day: &day, + ProjectFlockKandang: &entity.ProjectFlockKandang{ + Id: 103, + ProjectFlock: entity.ProjectFlock{ + Id: 52, + Category: string(utils.ProjectFlockCategoryLaying), + ProductionStandardId: 1, + ProductionStandard: entity.ProductionStandard{ + Id: 1, + Name: "STD Laying", + }, + }, + }, + } + + actualWeek := computeTransferAwareWeek(recording, map[uint]time.Time{103: chickInDate}) + if actualWeek != 13 { + t.Fatalf("expected actual transfer-aware week 13, got %d", actualWeek) + } + + if err := AttachProductionStandards(context.Background(), db, false, nil, recording); err != nil { + t.Fatalf("expected attach standard to succeed, got %v", err) + } + + if recording.Day == nil || *recording.Day != 91 { + t.Fatalf("expected actual recording day to remain 91, got %+v", recording.Day) + } + if recording.StandardWeek == nil || *recording.StandardWeek != 18 { + t.Fatalf("expected effective standard week 18, got %+v", recording.StandardWeek) + } + if recording.StandardFeedIntake == nil || *recording.StandardFeedIntake != 120 { + t.Fatalf("expected feed intake std from week 18, got %+v", recording.StandardFeedIntake) + } + if recording.StandardHenDay == nil || *recording.StandardHenDay != 80 { + t.Fatalf("expected hen day std from week 18, got %+v", recording.StandardHenDay) + } + if recording.StandardFcr == nil || *recording.StandardFcr != 2.1 { + t.Fatalf("expected fcr std from week 18, got %+v", recording.StandardFcr) + } +} + +func setupAttachProductionStandardTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + statements := []string{ + `CREATE TABLE production_standard_details ( + id INTEGER PRIMARY KEY, + production_standard_id INTEGER NOT NULL, + week INTEGER NOT NULL, + target_hen_day_production NUMERIC NULL, + target_hen_house_production NUMERIC NULL, + target_egg_weight NUMERIC NULL, + target_egg_mass NUMERIC NULL, + standard_fcr NUMERIC NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL + )`, + `CREATE TABLE standard_growth_details ( + id INTEGER PRIMARY KEY, + production_standard_id INTEGER NOT NULL, + target_mean_bw NUMERIC NULL, + max_depletion NUMERIC NULL, + min_uniformity NUMERIC NOT NULL, + week INTEGER NOT NULL, + feed_intake NUMERIC NULL, + created_at TIMESTAMP NULL, + created_by INTEGER NOT NULL + )`, + `CREATE TABLE laying_transfer_targets ( + id INTEGER PRIMARY KEY, + laying_transfer_id INTEGER NOT NULL, + target_project_flock_kandang_id INTEGER NOT NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE laying_transfers ( + id INTEGER PRIMARY KEY, + source_project_flock_kandang_id INTEGER NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE project_chickins ( + id INTEGER PRIMARY KEY, + project_flock_kandang_id INTEGER NOT NULL, + chick_in_date TIMESTAMP NOT NULL, + deleted_at TIMESTAMP NULL + )`, + `INSERT INTO production_standard_details + (id, production_standard_id, week, target_hen_day_production, target_hen_house_production, target_egg_weight, target_egg_mass, standard_fcr) + VALUES (1, 1, 18, 80, 70, 55, 44, 2.1)`, + `INSERT INTO standard_growth_details + (id, production_standard_id, week, feed_intake, max_depletion, min_uniformity, created_by) + VALUES (1, 1, 18, 120, 1.5, 80, 1)`, + `INSERT INTO laying_transfers (id, source_project_flock_kandang_id, deleted_at) VALUES + (77, 83, NULL)`, + `INSERT INTO laying_transfer_targets (id, laying_transfer_id, target_project_flock_kandang_id, deleted_at) VALUES + (88, 77, 103, NULL)`, + `INSERT INTO project_chickins (id, project_flock_kandang_id, chick_in_date, deleted_at) VALUES + (99, 83, '2026-01-01 00:00:00', NULL)`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return db +}