diff --git a/internal/database/migrations/20251018072532_project_flock_kandangs.down.sql b/internal/database/migrations/20251018072532_project_flock_kandangs.down.sql new file mode 100644 index 00000000..fe912389 --- /dev/null +++ b/internal/database/migrations/20251018072532_project_flock_kandangs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_flock_kandangs; diff --git a/internal/database/migrations/20251018072532_project_flock_kandangs.up.sql b/internal/database/migrations/20251018072532_project_flock_kandangs.up.sql new file mode 100644 index 00000000..aba14be3 --- /dev/null +++ b/internal/database/migrations/20251018072532_project_flock_kandangs.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE project_flock_kandangs ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL REFERENCES project_flocks (id) ON DELETE CASCADE ON UPDATE CASCADE, + kandang_id BIGINT NOT NULL REFERENCES kandangs (id) ON DELETE CASCADE ON UPDATE CASCADE, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + detached_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_project_flock_kandangs_project ON project_flock_kandangs (project_flock_id); +CREATE INDEX idx_project_flock_kandangs_kandang ON project_flock_kandangs (kandang_id); + +CREATE UNIQUE INDEX idx_project_flock_kandangs_active ON project_flock_kandangs (project_flock_id, kandang_id) +WHERE + detached_at IS NULL; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 21ce1a76..afa2a308 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -378,6 +378,9 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users if err := tx.Create(&kandang).Error; err != nil { return nil, err } + if err := syncPivotRelation(tx, projectFlockID, kandang.Id, createdBy); err != nil { + return nil, err + } } else if err != nil { return nil, err } else { @@ -394,6 +397,9 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users if err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { return nil, err } + if err := syncPivotRelation(tx, projectFlockID, kandang.Id, createdBy); err != nil { + return nil, err + } } result[seed.Name] = kandang.Id } @@ -401,6 +407,40 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return result, nil } +func syncPivotRelation(tx *gorm.DB, projectFlockID *uint, kandangID uint, createdBy uint) error { + if err := detachActivePivot(tx, kandangID); err != nil { + return err + } + if projectFlockID == nil { + return nil + } + return ensureActivePivot(tx, *projectFlockID, kandangID, createdBy) +} + +func detachActivePivot(tx *gorm.DB, kandangID uint) error { + return tx.Model(&entity.ProjectFlockKandang{}). + Where("kandang_id = ? AND detached_at IS NULL", kandangID). + Updates(map[string]any{"detached_at": time.Now()}).Error +} + +func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID, createdBy uint) error { + var pivot entity.ProjectFlockKandang + err := tx.Where("project_flock_id = ? AND kandang_id = ? AND detached_at IS NULL", projectFlockID, kandangID). + First(&pivot).Error + if err == nil { + return nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + newRecord := entity.ProjectFlockKandang{ + ProjectFlockId: projectFlockID, + KandangId: kandangID, + CreatedBy: createdBy, + } + return tx.Create(&newRecord).Error +} + func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { seeds := []struct { Name string diff --git a/internal/entities/projectfloc.go b/internal/entities/projectfloc.go index eee7392a..2d581e84 100644 --- a/internal/entities/projectfloc.go +++ b/internal/entities/projectfloc.go @@ -7,22 +7,23 @@ import ( ) type ProjectFlock struct { - Id uint `gorm:"primaryKey"` - FlockId uint `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:1"` - AreaId uint `gorm:"not null"` - ProductCategoryId uint `gorm:"not null"` - FcrId uint `gorm:"not null"` - LocationId uint `gorm:"not null"` - Period int `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:2"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Flock Flock `gorm:"foreignKey:FlockId;references:Id"` - Area Area `gorm:"foreignKey:AreaId;references:Id"` - ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` - Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` - Location Location `gorm:"foreignKey:LocationId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` + Id uint `gorm:"primaryKey"` + FlockId uint `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:1"` + AreaId uint `gorm:"not null"` + ProductCategoryId uint `gorm:"not null"` + FcrId uint `gorm:"not null"` + LocationId uint `gorm:"not null"` + Period int `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:2"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Flock Flock `gorm:"foreignKey:FlockId;references:Id"` + Area Area `gorm:"foreignKey:AreaId;references:Id"` + ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` + Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` + KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id"` } diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go new file mode 100644 index 00000000..0014a815 --- /dev/null +++ b/internal/entities/projectflock_kandang.go @@ -0,0 +1,17 @@ +package entities + +import "time" + +type ProjectFlockKandang struct { + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_active,priority:1,where:detached_at IS NULL"` + KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_active,priority:2,where:detached_at IS NULL"` + CreatedBy uint `gorm:"not null"` + AssignedAt time.Time `gorm:"autoCreateTime"` + DetachedAt *time.Time `gorm:"index"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` + Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 48134164..a1f2e263 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -1,8 +1,11 @@ package controller import ( + "encoding/json" + "fmt" "math" "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" @@ -23,10 +26,58 @@ func NewProjectflockController(projectflockService service.ProjectflockService) } func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { + parseUintList := func(raw string) ([]uint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + var ids []uint + if strings.HasPrefix(raw, "[") { + if err := json.Unmarshal([]byte(raw), &ids); err == nil { + return ids, nil + } + } + + parts := strings.Split(raw, ",") + for _, part := range parts { + part = strings.Trim(part, " \"[]") + if part == "" { + continue + } + v, err := strconv.Atoi(part) + if err != nil || v <= 0 { + return nil, fmt.Errorf("invalid kandang id: %s", part) + } + ids = append(ids, uint(v)) + } + return ids, nil + } + 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", ""), + SortBy: c.Query("sort_by", ""), + SortOrder: c.Query("sort_order", ""), + } + + if area := c.QueryInt("area_id", 0); area > 0 { + query.AreaId = uint(area) + } + if location := c.QueryInt("location_id", 0); location > 0 { + query.LocationId = uint(location) + } + if period := c.QueryInt("period", 0); period > 0 { + query.Period = period + } + + if kandangRaw := c.Query("kandang_id", c.Query("kandang_ids", "")); kandangRaw != "" { + ids, err := parseUintList(kandangRaw) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + query.KandangIds = ids } result, totalResults, err := u.ProjectflockService.GetAll(c, query) diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index 227d0fe9..a42caebf 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -8,24 +8,24 @@ import ( ) type ProjectFlockBaseDTO struct { - Id uint `json:"id"` + Id uint `json:"id"` // FlockId uint `json:"flock_id"` // AreaId uint `json:"area_id"` // ProductCategoryId uint `json:"product_category_id"` // FcrId uint `json:"fcr_id"` // LocationId uint `json:"location_id"` - Period int `json:"period"` + Period int `json:"period"` } func ToProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { return ProjectFlockBaseDTO{ - Id: e.Id, + Id: e.Id, // FlockId: e.FlockId, // AreaId: e.AreaId, // ProductCategoryId: e.ProductCategoryId, // FcrId: e.FcrId, // LocationId: e.LocationId, - Period: e.Period, + Period: e.Period, } } diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 4f3167bc..5f1afbe3 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -20,9 +20,10 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid flockRepo := rFlock.NewFlockRepository(db) kandangRepo := rKandang.NewKandangRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db) + projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go new file mode 100644 index 00000000..9b89a399 --- /dev/null +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProjectFlockKandangRepository interface { + CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error + MarkDetached(ctx context.Context, projectFlockID uint, kandangIDs []uint, detachedAt time.Time) error + GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) + WithTx(tx *gorm.DB) ProjectFlockKandangRepository + DB() *gorm.DB +} + +type projectFlockKandangRepositoryImpl struct { + db *gorm.DB +} + +func NewProjectFlockKandangRepository(db *gorm.DB) ProjectFlockKandangRepository { + return &projectFlockKandangRepositoryImpl{db: db} +} + +func (r *projectFlockKandangRepositoryImpl) CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error { + if len(records) == 0 { + return nil + } + return r.db.WithContext(ctx).Create(&records).Error +} + +func (r *projectFlockKandangRepositoryImpl) MarkDetached(ctx context.Context, projectFlockID uint, kandangIDs []uint, detachedAt time.Time) error { + if len(kandangIDs) == 0 { + return nil + } + return r.db.WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ? AND kandang_id IN ? AND detached_at IS NULL", projectFlockID, kandangIDs). + Updates(map[string]any{"detached_at": detachedAt}).Error +} + +func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) { + var records []entity.ProjectFlockKandang + if err := r.db.WithContext(ctx). + Preload("ProjectFlock"). + Preload("ProjectFlock.Flock"). + Preload("Kandang"). + Preload("CreatedUser"). + Order("project_flock_id ASC, assigned_at ASC"). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository { + return &projectFlockKandangRepositoryImpl{db: tx} +} + +func (r *projectFlockKandangRepositoryImpl) DB() *gorm.DB { + return r.db +} diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index e9ad3ddb..8af6e452 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -4,6 +4,8 @@ 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" @@ -35,6 +37,7 @@ type projectflockService struct { Repository repository.ProjectflockRepository FlockRepo flockRepository.FlockRepository KandangRepo kandangRepository.KandangRepository + PivotRepo repository.ProjectFlockKandangRepository } type FlockPeriodSummary struct { @@ -46,6 +49,7 @@ func NewProjectflockService( repo repository.ProjectflockRepository, flockRepo flockRepository.FlockRepository, kandangRepo kandangRepository.KandangRepository, + pivotRepo repository.ProjectFlockKandangRepository, validate *validator.Validate, ) ProjectflockService { return &projectflockService{ @@ -54,6 +58,7 @@ func NewProjectflockService( Repository: repo, FlockRepo: flockRepo, KandangRepo: kandangRepo, + PivotRepo: pivotRepo, } } @@ -73,11 +78,81 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e 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) - return db.Order("created_at DESC").Order("updated_at DESC") + + 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 { @@ -167,12 +242,10 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } - if err := tx.Model(&entity.Kandang{}). - Where("id IN ?", kandangIDs). - Updates(map[string]any{"project_flock_id": createBody.Id}).Error; err != nil { + if err := s.attachKandangs(c.Context(), tx, createBody.Id, kandangIDs, createBody.CreatedBy); err != nil { tx.Rollback() - s.Log.Errorf("Failed to assign kandangs to projectflock: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to assign kandangs") + s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", createBody.Id, err) + return nil, err } if err := tx.Commit().Error; err != nil { @@ -315,22 +388,18 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if len(toDetach) > 0 { - if err := tx.Model(&entity.Kandang{}). - Where("id IN ?", toDetach). - Updates(map[string]any{"project_flock_id": nil}).Error; err != nil { + if err := s.detachKandangs(c.Context(), tx, id, toDetach, false); err != nil { tx.Rollback() - s.Log.Errorf("Failed to detach kandangs: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + s.Log.Errorf("Failed to detach kandangs from projectflock %d: %+v", id, err) + return nil, err } } if len(toAttach) > 0 { - if err := tx.Model(&entity.Kandang{}). - Where("id IN ?", toAttach). - Updates(map[string]any{"project_flock_id": id}).Error; err != nil { + if err := s.attachKandangs(c.Context(), tx, id, toAttach, existing.CreatedBy); err != nil { tx.Rollback() - s.Log.Errorf("Failed to attach kandangs: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", id, err) + return nil, err } } } @@ -363,12 +432,10 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { for i, k := range existing.Kandangs { ids[i] = k.Id } - if err := tx.Model(&entity.Kandang{}). - Where("id IN ?", ids). - Updates(map[string]any{"project_flock_id": nil}).Error; err != nil { + if err := s.detachKandangs(c.Context(), tx, id, ids, true); err != nil { tx.Rollback() - s.Log.Errorf("Failed to detach kandangs before delete: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + s.Log.Errorf("Failed to detach kandangs before deleting projectflock %d: %+v", id, err) + return err } } @@ -431,3 +498,93 @@ func relationExistsChecker[T any](db *gorm.DB) func(context.Context, uint) (bool 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) +} diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 8c1f7d06..0d8d3a80 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -20,7 +20,13 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=area location kandangs period"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + AreaId uint `query:"area_id" validate:"omitempty,number,gt=0"` + LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` + Period int `query:"period" validate:"omitempty,number,gt=0"` + KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` } diff --git a/test/integration/master_data/master_data.go b/test/integration/master_data/master_data.go index f206808f..d43ddf15 100644 --- a/test/integration/master_data/master_data.go +++ b/test/integration/master_data/master_data.go @@ -42,6 +42,7 @@ func setupIntegrationApp(t *testing.T) (*fiber.App, *gorm.DB) { &entities.Location{}, &entities.Flock{}, &entities.ProjectFlock{}, + &entities.ProjectFlockKandang{}, &entities.Kandang{}, &entities.Warehouse{}, &entities.Uom{}, @@ -191,6 +192,15 @@ func fetchCustomer(t *testing.T, db *gorm.DB, id uint) entities.Customer { return customer } +func fetchKandang(t *testing.T, db *gorm.DB, id uint) entities.Kandang { + t.Helper() + var kandang entities.Kandang + if err := db.Preload("ProjectFlock").First(&kandang, id).Error; err != nil { + t.Fatalf("failed to fetch kandang: %v", err) + } + return kandang +} + func createSupplier(t *testing.T, app *fiber.App, name, alias, category string) uint { t.Helper() identifier := strings.ToLower(strings.ReplaceAll(name, " ", "_")) diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go index 59698ae9..c5e0442c 100644 --- a/test/integration/master_data/project_flock_test.go +++ b/test/integration/master_data/project_flock_test.go @@ -4,13 +4,17 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "testing" "github.com/gofiber/fiber/v2" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) func TestProjectFlockSummary(t *testing.T) { - app, _ := setupIntegrationApp(t) + app, db := setupIntegrationApp(t) areaID := createArea(t, app, "Area Project") locationID := createLocation(t, app, "Location Project", "Address", areaID) @@ -95,6 +99,21 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) } + var pivotRecords []entities.ProjectFlockKandang + if err := db.Where("project_flock_id = ?", createResp.Data.Id).Find(&pivotRecords).Error; err != nil { + t.Fatalf("failed to fetch pivot records: %v", err) + } + if len(pivotRecords) != 1 { + t.Fatalf("expected 1 pivot record, got %d", len(pivotRecords)) + } + firstPivotRecord := pivotRecords[0] + if firstPivotRecord.KandangId != kandangID { + t.Fatalf("expected pivot kandang id %d, got %d", kandangID, firstPivotRecord.KandangId) + } + if firstPivotRecord.DetachedAt != nil { + t.Fatalf("expected pivot DetachedAt to be nil for active assignment, got %v", firstPivotRecord.DetachedAt) + } + secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) secondPayload := map[string]any{ "flock_id": flockID, @@ -121,6 +140,21 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected second period to be 2, got %d", createRespSecond.Data.Period) } + pivotRecords = nil + if err := db.Where("project_flock_id = ?", createRespSecond.Data.Id).Find(&pivotRecords).Error; err != nil { + t.Fatalf("failed to fetch second pivot records: %v", err) + } + if len(pivotRecords) != 1 { + t.Fatalf("expected 1 pivot record for second project, got %d", len(pivotRecords)) + } + secondPivotRecord := pivotRecords[0] + if secondPivotRecord.KandangId != secondKandangID { + t.Fatalf("expected second pivot kandang id %d, got %d", secondKandangID, secondPivotRecord.KandangId) + } + if secondPivotRecord.DetachedAt != nil { + t.Fatalf("expected second pivot DetachedAt to be nil, got %v", secondPivotRecord.DetachedAt) + } + resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) if resp.StatusCode != fiber.StatusOK { t.Fatalf("expected 200 when fetching summary, got %d: %s", resp.StatusCode, string(body)) @@ -144,11 +178,49 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected 200 when deleting first project flock, got %d: %s", resp.StatusCode, string(body)) } + firstKandang := fetchKandang(t, db, kandangID) + if firstKandang.ProjectFlockId != nil { + t.Fatalf("expected project_flock_id to be nil after delete, got %v", *firstKandang.ProjectFlockId) + } + if firstKandang.Status != string(utils.KandangStatusNonActive) { + t.Fatalf("expected kandang status to revert to NON_ACTIVE, got %s", firstKandang.Status) + } + + var firstPivot entities.ProjectFlockKandang + if err := db.First(&firstPivot, firstPivotRecord.Id).Error; err != nil { + t.Fatalf("failed to reload first pivot record: %v", err) + } + if firstPivot.DetachedAt == nil { + t.Fatalf("expected first pivot DetachedAt to be set after delete") + } + if firstPivot.ProjectFlockId != createResp.Data.Id { + t.Fatalf("expected first pivot project_flock_id %d, got %d", createResp.Data.Id, firstPivot.ProjectFlockId) + } + resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createRespSecond.Data.Id), nil) if resp.StatusCode != fiber.StatusOK { t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body)) } + secondKandang := fetchKandang(t, db, secondKandangID) + if secondKandang.ProjectFlockId != nil { + t.Fatalf("expected second project_flock_id to be nil after delete, got %v", *secondKandang.ProjectFlockId) + } + if secondKandang.Status != string(utils.KandangStatusNonActive) { + t.Fatalf("expected second kandang status to revert to NON_ACTIVE, got %s", secondKandang.Status) + } + + var secondPivot entities.ProjectFlockKandang + if err := db.First(&secondPivot, secondPivotRecord.Id).Error; err != nil { + t.Fatalf("failed to reload second pivot record: %v", err) + } + if secondPivot.DetachedAt == nil { + t.Fatalf("expected second pivot DetachedAt to be set after delete") + } + if secondPivot.ProjectFlockId != createRespSecond.Data.Id { + t.Fatalf("expected second pivot project_flock_id %d, got %d", createRespSecond.Data.Id, secondPivot.ProjectFlockId) + } + resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) if resp.StatusCode != fiber.StatusOK { t.Fatalf("expected 200 when fetching summary after delete, got %d: %s", resp.StatusCode, string(body)) @@ -166,3 +238,178 @@ func TestProjectFlockSummary(t *testing.T) { func uintToString(v uint) string { return fmt.Sprintf("%d", v) } + +func TestProjectFlockSearchByRelatedFields(t *testing.T) { + app, _ := setupIntegrationApp(t) + + areaID := createArea(t, app, "Area Search Target") + locationID := createLocation(t, app, "Location Search Target", "Location Address Target", areaID) + flockID := createFlock(t, app, "Flock Search Target") + categoryID := createProductCategory(t, app, "Category Search Target", "CATGT") + fcrID := createFcr(t, app, "FCR Search Target", []map[string]any{ + {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, + }) + kandangID := createKandang(t, app, "Kandang Search Target", locationID, 1) + + createPayload := map[string]any{ + "flock_id": flockID, + "area_id": areaID, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationID, + "kandang_ids": []uint{kandangID}, + } + + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) + } + + var createResp struct { + Data struct { + Id uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &createResp); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + + searchTerms := []string{ + "Flock Search Target", + "Area Search Target", + "Category Search Target", + "CATGT", + "FCR Search Target", + "Kandang Search Target", + "Location Search Target", + "Location Address Target", + "Tester", + "1", + } + + for _, term := range searchTerms { + path := "/api/production/project_flocks?search=" + url.QueryEscape(term) + resp, body := doJSONRequest(t, app, http.MethodGet, path, nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when searching for %q, got %d: %s", term, resp.StatusCode, string(body)) + } + + var listResp struct { + Data []struct { + Id uint `json:"id"` + } `json:"data"` + Meta struct { + TotalResults int64 `json:"total_results"` + } `json:"meta"` + } + if err := json.Unmarshal(body, &listResp); err != nil { + t.Fatalf("failed to parse list response for %q: %v", term, err) + } + if listResp.Meta.TotalResults == 0 { + t.Fatalf("expected at least one result when searching for %q", term) + } + if len(listResp.Data) == 0 { + t.Fatalf("expected data when searching for %q", term) + } + if listResp.Data[0].Id != createResp.Data.Id { + t.Fatalf("expected project flock id %d for search term %q, got %d", createResp.Data.Id, term, listResp.Data[0].Id) + } + } +} + +func TestProjectFlockSorting(t *testing.T) { + app, _ := setupIntegrationApp(t) + + areaA := createArea(t, app, "Area Alpha") + areaB := createArea(t, app, "Area Beta") + + locationA := createLocation(t, app, "Location Alpha", "Address Alpha", areaA) + locationB := createLocation(t, app, "Location Beta", "Address Beta", areaB) + + flockOne := createFlock(t, app, "Flock Sort One") + flockTwo := createFlock(t, app, "Flock Sort Two") + + categoryID := createProductCategory(t, app, "Category Sort", "CSORT") + fcrID := createFcr(t, app, "FCR Sort", []map[string]any{ + {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, + }) + + kandangOne := createKandang(t, app, "Kandang Sort One", locationA, 1) + kandangTwo := createKandang(t, app, "Kandang Sort Two", locationB, 1) + kandangThree := createKandang(t, app, "Kandang Sort Three", locationB, 1) + + projectOnePayload := map[string]any{ + "flock_id": flockOne, + "area_id": areaA, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationA, + "kandang_ids": []uint{kandangOne}, + } + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectOnePayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 for project one, got %d: %s", resp.StatusCode, string(body)) + } + projectOneID := parseProjectFlockID(t, body) + + projectTwoPayload := map[string]any{ + "flock_id": flockTwo, + "area_id": areaB, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationB, + "kandang_ids": []uint{kandangTwo, kandangThree}, + } + resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectTwoPayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 for project two, got %d: %s", resp.StatusCode, string(body)) + } + projectTwoID := parseProjectFlockID(t, body) + + updatePeriodPayload := map[string]any{"period": 5} + resp, body = doJSONRequest(t, app, http.MethodPatch, "/api/production/project_flocks/"+uintToString(projectTwoID), updatePeriodPayload) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating period, got %d: %s", resp.StatusCode, string(body)) + } + + assertOrder := func(t *testing.T, app *fiber.App, query string, expectedFirst uint) { + t.Helper() + resp, body := doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks?"+query, nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 for query %q, got %d: %s", query, resp.StatusCode, string(body)) + } + var listResp struct { + Data []struct { + Id uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &listResp); err != nil { + t.Fatalf("failed to parse list response for %q: %v", query, err) + } + if len(listResp.Data) == 0 { + t.Fatalf("expected data for query %q", query) + } + if listResp.Data[0].Id != expectedFirst { + t.Fatalf("expected first id %d for query %q, got %d", expectedFirst, query, listResp.Data[0].Id) + } + } + + assertOrder(t, app, "sort_by=area&sort_order=asc", projectOneID) + assertOrder(t, app, "sort_by=location&sort_order=desc", projectTwoID) + assertOrder(t, app, "sort_by=period&sort_order=desc", projectTwoID) + assertOrder(t, app, "sort_by=kandangs&sort_order=desc", projectTwoID) + assertOrder(t, app, "sort_by=kandangs&sort_order=asc", projectOneID) +} + +func parseProjectFlockID(t *testing.T, body []byte) uint { + t.Helper() + var resp struct { + Data struct { + Id uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to parse project flock response: %v", err) + } + return resp.Data.Id +}