diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 4383ee4a..b200cb18 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -347,7 +347,10 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint func (r *projectFlockKandangRepositoryImpl) GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) { record := new(entity.ProjectFlockKandang) if err := r.db.WithContext(ctx). + Preload("Kandang"). + Preload("Kandang.Location"). Preload("ProjectFlock"). + Preload("ProjectFlock.Location"). First(record, id).Error; err != nil { return nil, err } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index f5691b57..7a55191c 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -355,6 +355,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + if req.Eggs, err = s.resolveEggRequestsToFarmWarehouses(ctx, pfk, actorID, req.Eggs); err != nil { + return nil, err + } + if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err } @@ -379,10 +387,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { return nil, err } - actorID, err := m.ActorIDFromContext(c) - if err != nil { - return nil, err - } var createdRecording entity.Recording transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if s.ProductionStandardSvc != nil { @@ -687,6 +691,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to list existing eggs: %+v", err) return err } + normalizeEggWarehouses, err := s.shouldNormalizeEggRequestsOnUpdate(ctx, existingEggs) + if err != nil { + return err + } + if normalizeEggWarehouses { + if req.Eggs, err = s.resolveEggRequestsToFarmWarehouses(ctx, pfkForRoute, actorID, req.Eggs); err != nil { + return err + } + } existingTotals := recordingutil.EggTotalsByWarehouse(existingEggs, func(egg entity.RecordingEgg) (uint, int, *float64) { return egg.ProductWarehouseId, egg.Qty, egg.Weight }) @@ -1492,6 +1505,194 @@ func boolPtr(value bool) *bool { return &v } +func (s *recordingService) resolveEggRequestsToFarmWarehouses( + ctx context.Context, + pfk *entity.ProjectFlockKandang, + actorID uint, + eggs []validation.Egg, +) ([]validation.Egg, error) { + if len(eggs) == 0 { + return eggs, nil + } + + locationID, farmName := farmContextFromProjectFlockKandang(pfk) + if locationID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Farm recording tidak valid") + } + + farmWarehouse, err := s.findFirstFarmWarehouse(ctx, locationID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Farm %s belum memiliki gudang", farmName)) + } + s.Log.Errorf("Failed to resolve farm warehouse for egg recording: %+v", err) + return nil, err + } + + idSet := make(map[uint]struct{}, len(eggs)) + for _, egg := range eggs { + if egg.ProductWarehouseId != 0 { + idSet[egg.ProductWarehouseId] = struct{}{} + } + } + if len(idSet) == 0 { + return eggs, nil + } + + ids := make([]uint, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + + var sourceWarehouses []entity.ProductWarehouse + if err := s.ProductWarehouseRepo.DB().WithContext(ctx). + Preload("Warehouse"). + Where("id IN ?", ids). + Find(&sourceWarehouses).Error; err != nil { + s.Log.Errorf("Failed to load egg source product warehouses: %+v", err) + return nil, err + } + if len(sourceWarehouses) != len(ids) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Product warehouse telur tidak ditemukan") + } + + sourceByID := make(map[uint]entity.ProductWarehouse, len(sourceWarehouses)) + resolvedBySource := make(map[uint]uint, len(sourceWarehouses)) + for _, source := range sourceWarehouses { + if err := ensureEggSourceMatchesRecordingScope(source, locationID, pfk.KandangId); err != nil { + return nil, err + } + sourceByID[source.Id] = source + } + + normalized := make([]validation.Egg, len(eggs)) + copy(normalized, eggs) + for i := range normalized { + source := sourceByID[normalized[i].ProductWarehouseId] + if resolvedID, ok := resolvedBySource[source.Id]; ok { + normalized[i].ProductWarehouseId = resolvedID + continue + } + + resolvedID, err := s.ProductWarehouseRepo.EnsureProductWarehouse(ctx, source.ProductId, farmWarehouse.Id, nil, actorID) + if err != nil { + s.Log.Errorf("Failed to ensure egg farm product warehouse: %+v", err) + return nil, err + } + resolvedBySource[source.Id] = resolvedID + normalized[i].ProductWarehouseId = resolvedID + } + + return normalized, nil +} + +func farmContextFromProjectFlockKandang(pfk *entity.ProjectFlockKandang) (uint, string) { + if pfk == nil { + return 0, "tidak diketahui" + } + + if pfk.ProjectFlock.LocationId != 0 { + name := strings.TrimSpace(pfk.ProjectFlock.Location.Name) + if name == "" { + name = strings.TrimSpace(pfk.Kandang.Location.Name) + } + if name == "" { + name = fmt.Sprintf("#%d", pfk.ProjectFlock.LocationId) + } + return pfk.ProjectFlock.LocationId, name + } + + if pfk.Kandang.LocationId != 0 { + name := strings.TrimSpace(pfk.Kandang.Location.Name) + if name == "" { + name = fmt.Sprintf("#%d", pfk.Kandang.LocationId) + } + return pfk.Kandang.LocationId, name + } + + return 0, "tidak diketahui" +} + +func (s *recordingService) findFirstFarmWarehouse(ctx context.Context, locationID uint) (*entity.Warehouse, error) { + var warehouse entity.Warehouse + if err := s.Repository.DB().WithContext(ctx). + Model(&entity.Warehouse{}). + Where("location_id = ? AND type = ?", locationID, utils.WarehouseTypeLokasi). + Order("id ASC"). + First(&warehouse).Error; err != nil { + return nil, err + } + return &warehouse, nil +} + +func ensureEggSourceMatchesRecordingScope(source entity.ProductWarehouse, locationID uint, kandangID uint) error { + if source.Id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Product warehouse telur tidak ditemukan") + } + if source.ProductId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Produk telur tidak valid") + } + if source.WarehouseId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Gudang telur tidak valid") + } + if source.Warehouse.LocationId == nil || *source.Warehouse.LocationId != locationID { + return fiber.NewError(fiber.StatusBadRequest, "Produk telur harus berasal dari farm yang sama") + } + + switch strings.ToUpper(strings.TrimSpace(source.Warehouse.Type)) { + case string(utils.WarehouseTypeLokasi): + return nil + case string(utils.WarehouseTypeKandang): + if source.Warehouse.KandangId == nil || *source.Warehouse.KandangId != kandangID { + return fiber.NewError(fiber.StatusBadRequest, "Produk telur harus berasal dari kandang recording yang sama") + } + return nil + default: + return fiber.NewError(fiber.StatusBadRequest, "Produk telur harus berasal dari gudang farm atau kandang recording") + } +} + +func (s *recordingService) shouldNormalizeEggRequestsOnUpdate(ctx context.Context, existingEggs []entity.RecordingEgg) (bool, error) { + if len(existingEggs) == 0 { + return true, nil + } + + idSet := make(map[uint]struct{}, len(existingEggs)) + for _, egg := range existingEggs { + if egg.ProductWarehouseId != 0 { + idSet[egg.ProductWarehouseId] = struct{}{} + } + } + if len(idSet) == 0 { + return true, nil + } + + ids := make([]uint, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + + var productWarehouses []entity.ProductWarehouse + if err := s.ProductWarehouseRepo.DB().WithContext(ctx). + Preload("Warehouse"). + Where("id IN ?", ids). + Find(&productWarehouses).Error; err != nil { + s.Log.Errorf("Failed to load existing egg product warehouses: %+v", err) + return false, err + } + if len(productWarehouses) != len(ids) { + return false, fiber.NewError(fiber.StatusBadRequest, "Product warehouse telur tidak ditemukan") + } + + for _, productWarehouse := range productWarehouses { + if strings.EqualFold(strings.TrimSpace(productWarehouse.Warehouse.Type), string(utils.WarehouseTypeLokasi)) { + return true, nil + } + } + + return false, nil +} + func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { idSet := make(map[uint]struct{}) diff --git a/internal/modules/production/recordings/services/recording.service_test.go b/internal/modules/production/recordings/services/recording.service_test.go new file mode 100644 index 00000000..f1bc6abf --- /dev/null +++ b/internal/modules/production/recordings/services/recording.service_test.go @@ -0,0 +1,195 @@ +package service + +import ( + "context" + "strings" + "testing" + + "github.com/glebarez/sqlite" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + "gorm.io/gorm" +) + +func TestResolveEggRequestsToFarmWarehousesChoosesFirstFarmWarehouse(t *testing.T) { + db := setupRecordingServiceTestDB(t) + repo := repository.NewRecordingRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + svc := &recordingService{ + Log: nil, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + } + + pfk := &entity.ProjectFlockKandang{ + Id: 10, + KandangId: 59, + ProjectFlock: entity.ProjectFlock{ + LocationId: 16, + Location: entity.Location{Name: "Jamali"}, + }, + Kandang: entity.Kandang{ + Id: 59, + LocationId: 16, + Location: entity.Location{Name: "Jamali"}, + }, + } + + got, err := svc.resolveEggRequestsToFarmWarehouses(context.Background(), pfk, 9, []validation.Egg{ + {ProductWarehouseId: 101, Qty: 120}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 egg row, got %d", len(got)) + } + if got[0].ProductWarehouseId == 101 { + t.Fatalf("expected egg warehouse to be remapped to farm warehouse") + } + + var resolved entity.ProductWarehouse + if err := db.WithContext(context.Background()). + Preload("Warehouse"). + First(&resolved, got[0].ProductWarehouseId).Error; err != nil { + t.Fatalf("failed to load resolved product warehouse: %v", err) + } + + if resolved.ProductId != 8 { + t.Fatalf("expected product_id 8, got %d", resolved.ProductId) + } + if resolved.WarehouseId != 21 { + t.Fatalf("expected first farm warehouse id 21, got %d", resolved.WarehouseId) + } + if resolved.ProjectFlockKandangId != nil { + t.Fatalf("expected farm-level product warehouse to remain shared, got pfk %+v", resolved.ProjectFlockKandangId) + } +} + +func TestResolveEggRequestsToFarmWarehousesFailsWhenFarmHasNoWarehouse(t *testing.T) { + db := setupRecordingServiceTestDB(t) + if err := db.Exec("DELETE FROM warehouses WHERE type = 'LOKASI'").Error; err != nil { + t.Fatalf("failed to remove farm warehouses: %v", err) + } + + repo := repository.NewRecordingRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + svc := &recordingService{ + Log: nil, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + } + + pfk := &entity.ProjectFlockKandang{ + Id: 10, + KandangId: 59, + ProjectFlock: entity.ProjectFlock{ + LocationId: 16, + Location: entity.Location{Name: "Jamali"}, + }, + } + + _, err := svc.resolveEggRequestsToFarmWarehouses(context.Background(), pfk, 9, []validation.Egg{ + {ProductWarehouseId: 101, Qty: 120}, + }) + if err == nil { + t.Fatal("expected validation error when farm warehouse is missing") + } + if !strings.Contains(err.Error(), "Farm Jamali belum memiliki gudang") { + t.Fatalf("expected missing farm warehouse error, got %v", err) + } +} + +func TestShouldNormalizeEggRequestsOnUpdatePreservesHistoricalKandangEggs(t *testing.T) { + db := setupRecordingServiceTestDB(t) + repo := repository.NewRecordingRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + svc := &recordingService{ + Log: nil, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + } + + shouldNormalize, err := svc.shouldNormalizeEggRequestsOnUpdate(context.Background(), []entity.RecordingEgg{ + {ProductWarehouseId: 101, Qty: 120}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if shouldNormalize { + t.Fatal("expected historical kandang-level egg rows to remain kandang-level on update") + } +} + +func TestShouldNormalizeEggRequestsOnUpdateNormalizesFarmLevelEggs(t *testing.T) { + db := setupRecordingServiceTestDB(t) + if err := db.Exec(`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES (201, 8, 21, NULL, 300)`).Error; err != nil { + t.Fatalf("failed to insert farm-level egg warehouse: %v", err) + } + + repo := repository.NewRecordingRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + svc := &recordingService{ + Log: nil, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + } + + shouldNormalize, err := svc.shouldNormalizeEggRequestsOnUpdate(context.Background(), []entity.RecordingEgg{ + {ProductWarehouseId: 201, Qty: 120}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !shouldNormalize { + t.Fatal("expected farm-level egg rows to keep using farm normalization on update") + } +} + +func setupRecordingServiceTestDB(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 locations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + )`, + `CREATE TABLE warehouses ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + location_id INTEGER NULL, + kandang_id INTEGER NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE product_warehouses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL, + warehouse_id INTEGER NOT NULL, + project_flock_kandang_id INTEGER NULL, + qty NUMERIC NULL + )`, + `INSERT INTO locations (id, name) VALUES (16, 'Jamali')`, + `INSERT INTO warehouses (id, name, type, location_id, kandang_id, deleted_at) VALUES + (21, 'Gudang Farm Jamali A', 'LOKASI', 16, NULL, NULL), + (25, 'Gudang Farm Jamali B', 'LOKASI', 16, NULL, NULL), + (46, 'Gudang Jamali 1', 'KANDANG', 16, 59, NULL)`, + `INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES + (101, 8, 46, 10, 500)`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return db +}