package service import ( "context" "errors" "fmt" "strings" "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" ) type ProjectflockService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) DeleteOne(ctx *fiber.Ctx, id uint) error GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) } type projectflockService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.ProjectflockRepository FlockRepo flockRepository.FlockRepository KandangRepo kandangRepository.KandangRepository PivotRepo repository.ProjectFlockKandangRepository } type FlockPeriodSummary struct { Flock entity.Flock NextPeriod int } func NewProjectflockService( repo repository.ProjectflockRepository, flockRepo flockRepository.FlockRepository, kandangRepo kandangRepository.KandangRepository, pivotRepo repository.ProjectFlockKandangRepository, validate *validator.Validate, ) ProjectflockService { return &projectflockService{ Log: utils.Log, Validate: validate, Repository: repo, FlockRepo: flockRepo, KandangRepo: kandangRepo, PivotRepo: pivotRepo, } } func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("Flock"). Preload("Area"). Preload("ProductCategory"). Preload("Fcr"). Preload("Location"). Preload("Kandangs") } func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } if params.Page <= 0 { params.Page = 1 } if params.Limit <= 0 { params.Limit = 10 } offset := (params.Page - 1) * params.Limit projectflocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.AreaId > 0 { db = db.Where("project_flocks.area_id = ?", params.AreaId) } if params.LocationId > 0 { db = db.Where("project_flocks.location_id = ?", params.LocationId) } if params.Period > 0 { db = db.Where("project_flocks.period = ?", params.Period) } if len(params.KandangIds) > 0 { db = db.Where("EXISTS (SELECT 1 FROM kandangs WHERE kandangs.project_flock_id = project_flocks.id AND kandangs.id IN ?)", params.KandangIds) } if params.Search != "" { normalizedSearch := strings.ToLower(strings.TrimSpace(params.Search)) if normalizedSearch == "" { for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { db = db.Order(expr) } return db } likeQuery := "%" + normalizedSearch + "%" db = db. Joins("LEFT JOIN flocks ON flocks.id = project_flocks.flock_id"). Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). Joins("LEFT JOIN product_categories ON product_categories.id = project_flocks.product_category_id"). Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id"). Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id"). Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). Where(` LOWER(flocks.name) LIKE ? OR LOWER(areas.name) LIKE ? OR LOWER(product_categories.name) LIKE ? OR LOWER(product_categories.code) LIKE ? OR LOWER(fcrs.name) LIKE ? OR LOWER(locations.name) LIKE ? OR LOWER(locations.address) LIKE ? OR LOWER(created_users.name) LIKE ? OR LOWER(created_users.email) LIKE ? OR LOWER(CAST(project_flocks.period AS TEXT)) LIKE ? OR EXISTS ( SELECT 1 FROM kandangs WHERE kandangs.project_flock_id = project_flocks.id AND LOWER(kandangs.name) LIKE ? ) `, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, ) } for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { db = db.Order(expr) } return db }) if err != nil { s.Log.Errorf("Failed to get projectflocks: %+v", err) return nil, 0, err } return projectflocks, total, nil } func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { projectflock, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } if err != nil { s.Log.Errorf("Failed get projectflock by id: %+v", err) return nil, err } return projectflock, nil } func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } if len(req.KandangIds) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required") } if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, common.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, common.RelationCheck{Name: "Product category", ID: &req.ProductCategoryId, Exists: relationExistsChecker[entity.ProductCategory](s.Repository.DB())}, common.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, common.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB())}, ); err != nil { return nil, err } kandangIDs := uniqueUintSlice(req.KandangIds) kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") } if len(kandangs) != len(kandangIDs) { return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") } for _, kandang := range kandangs { if kandang.ProjectFlockId != nil { return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang %s sudah memiliki project flock", kandang.Name)) } } tx := s.Repository.DB().Begin() if tx.Error != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") } projectRepo := repository.NewProjectflockRepository(tx) nextPeriod, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) if err != nil { tx.Rollback() s.Log.Errorf("Failed to determine next period for flock %d: %+v", req.FlockId, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine next period") } createBody := &entity.ProjectFlock{ FlockId: req.FlockId, AreaId: req.AreaId, ProductCategoryId: req.ProductCategoryId, FcrId: req.FcrId, LocationId: req.LocationId, Period: nextPeriod, CreatedBy: 1, } if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { tx.Rollback() if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") } s.Log.Errorf("Failed to create projectflock: %+v", err) return nil, err } if err := s.attachKandangs(c.Context(), tx, createBody.Id, kandangIDs, createBody.CreatedBy); err != nil { tx.Rollback() s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", createBody.Id, err) return nil, err } if err := tx.Commit().Error; err != nil { tx.Rollback() return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") } return s.GetOne(c, createBody.Id) } func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } if err != nil { s.Log.Errorf("Failed to fetch projectflock %d before update: %+v", id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } updateBody := make(map[string]any) var relationChecks []common.RelationCheck if req.FlockId != nil { updateBody["flock_id"] = *req.FlockId relationChecks = append(relationChecks, common.RelationCheck{ Name: "Flock", ID: req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB()), }) } if req.AreaId != nil { updateBody["area_id"] = *req.AreaId relationChecks = append(relationChecks, common.RelationCheck{ Name: "Area", ID: req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB()), }) } if req.ProductCategoryId != nil { updateBody["product_category_id"] = *req.ProductCategoryId relationChecks = append(relationChecks, common.RelationCheck{ Name: "Product category", ID: req.ProductCategoryId, Exists: relationExistsChecker[entity.ProductCategory](s.Repository.DB()), }) } if req.FcrId != nil { updateBody["fcr_id"] = *req.FcrId relationChecks = append(relationChecks, common.RelationCheck{ Name: "FCR", ID: req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB()), }) } if req.LocationId != nil { updateBody["location_id"] = *req.LocationId relationChecks = append(relationChecks, common.RelationCheck{ Name: "Location", ID: req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB()), }) } if req.Period != nil { updateBody["period"] = *req.Period } if len(relationChecks) > 0 { if err := common.EnsureRelations(c.Context(), relationChecks...); err != nil { return nil, err } } var newKandangIDs []uint if req.KandangIds != nil { if len(req.KandangIds) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids cannot be empty") } newKandangIDs = uniqueUintSlice(req.KandangIds) kandangs, err := s.KandangRepo.GetByIDs(c.Context(), newKandangIDs, nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") } if len(kandangs) != len(newKandangIDs) { return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") } for _, k := range kandangs { if k.ProjectFlockId != nil && *k.ProjectFlockId != id { return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang %s sudah terikat dengan project flock lain", k.Name)) } } } tx := s.Repository.DB().Begin() if tx.Error != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") } projectRepo := repository.NewProjectflockRepository(tx) if len(updateBody) > 0 { if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } s.Log.Errorf("Failed to update projectflock: %+v", err) return nil, err } } if req.KandangIds != nil { existingIDs := make(map[uint]struct{}, len(existing.Kandangs)) for _, k := range existing.Kandangs { existingIDs[k.Id] = struct{}{} } newSet := make(map[uint]struct{}, len(newKandangIDs)) for _, id := range newKandangIDs { newSet[id] = struct{}{} } var toDetach []uint for id := range existingIDs { if _, ok := newSet[id]; !ok { toDetach = append(toDetach, id) } } var toAttach []uint for id := range newSet { if _, ok := existingIDs[id]; !ok { toAttach = append(toAttach, id) } } if len(toDetach) > 0 { if err := s.detachKandangs(c.Context(), tx, id, toDetach, false); err != nil { tx.Rollback() s.Log.Errorf("Failed to detach kandangs from projectflock %d: %+v", id, err) return nil, err } } if len(toAttach) > 0 { if err := s.attachKandangs(c.Context(), tx, id, toAttach, existing.CreatedBy); err != nil { tx.Rollback() s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", id, err) return nil, err } } } if err := tx.Commit().Error; err != nil { tx.Rollback() return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") } return s.GetOne(c, id) } func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } if err != nil { s.Log.Errorf("Failed to fetch projectflock %d before delete: %+v", id, err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } tx := s.Repository.DB().Begin() if tx.Error != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") } if len(existing.Kandangs) > 0 { ids := make([]uint, len(existing.Kandangs)) for i, k := range existing.Kandangs { ids[i] = k.Id } if err := s.detachKandangs(c.Context(), tx, id, ids, true); err != nil { tx.Rollback() s.Log.Errorf("Failed to detach kandangs before deleting projectflock %d: %+v", id, err) return err } } if err := repository.NewProjectflockRepository(tx).DeleteOne(c.Context(), id); err != nil { tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } s.Log.Errorf("Failed to delete projectflock: %+v", err) return err } if err := tx.Commit().Error; err != nil { tx.Rollback() return fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") } return nil } func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { flock, err := s.FlockRepo.GetByID(c.Context(), flockID, func(db *gorm.DB) *gorm.DB { return db.Preload("CreatedUser") }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Flock not found") } if err != nil { s.Log.Errorf("Failed get flock %d for period summary: %+v", flockID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock") } maxPeriod, err := s.Repository.GetMaxPeriodByFlock(c.Context(), flockID) if err != nil { s.Log.Errorf("Failed to compute next period for flock %d: %+v", flockID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to compute next period") } return &FlockPeriodSummary{ Flock: *flock, NextPeriod: maxPeriod + 1, }, nil } func uniqueUintSlice(values []uint) []uint { seen := make(map[uint]struct{}, len(values)) result := make([]uint, 0, len(values)) for _, v := range values { if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} result = append(result, v) } return result } func relationExistsChecker[T any](db *gorm.DB) func(context.Context, uint) (bool, error) { return func(ctx context.Context, id uint) (bool, error) { return commonRepo.Exists[T](ctx, db, id) } } func (s projectflockService) buildOrderExpressions(sortBy, sortOrder string) []string { direction := "ASC" if strings.ToLower(sortOrder) == "desc" { direction = "DESC" } switch sortBy { case "area": return []string{ fmt.Sprintf("(SELECT name FROM areas WHERE areas.id = project_flocks.area_id) %s", direction), fmt.Sprintf("project_flocks.id %s", direction), } case "location": return []string{ fmt.Sprintf("(SELECT name FROM locations WHERE locations.id = project_flocks.location_id) %s", direction), fmt.Sprintf("project_flocks.id %s", direction), } case "kandangs": return []string{ fmt.Sprintf("(SELECT COUNT(*) FROM kandangs WHERE kandangs.project_flock_id = project_flocks.id) %s", direction), fmt.Sprintf("project_flocks.id %s", direction), } case "period": return []string{ fmt.Sprintf("project_flocks.period %s", direction), fmt.Sprintf("project_flocks.id %s", direction), } default: return []string{ "project_flocks.created_at DESC", "project_flocks.updated_at DESC", } } } func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, projectFlockID uint, kandangIDs []uint, createdBy uint) error { if len(kandangIDs) == 0 { return nil } if err := tx.Model(&entity.Kandang{}). Where("id IN ?", kandangIDs). Updates(map[string]any{"project_flock_id": projectFlockID}).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") } pivotRepo := s.pivotRepoWithTx(tx) records := make([]*entity.ProjectFlockKandang, len(kandangIDs)) for i, id := range kandangIDs { records[i] = &entity.ProjectFlockKandang{ ProjectFlockId: projectFlockID, KandangId: id, CreatedBy: createdBy, } } if err := pivotRepo.CreateMany(ctx, records); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil } func (s projectflockService) detachKandangs(ctx context.Context, tx *gorm.DB, projectFlockID uint, kandangIDs []uint, resetStatus bool) error { if len(kandangIDs) == 0 { return nil } updates := map[string]any{"project_flock_id": nil} if resetStatus { updates["status"] = string(utils.KandangStatusNonActive) } if err := tx.Model(&entity.Kandang{}). Where("id IN ?", kandangIDs). Updates(updates).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") } if err := s.pivotRepoWithTx(tx).MarkDetached(ctx, projectFlockID, kandangIDs, time.Now()); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil } func (s projectflockService) pivotRepoWithTx(tx *gorm.DB) repository.ProjectFlockKandangRepository { if s.PivotRepo == nil { return repository.NewProjectFlockKandangRepository(tx) } return s.PivotRepo.WithTx(tx) }