diff --git a/internal/database/migrations/20251024092758_deleted-project_flock_id_in_kandangs.up.sql b/internal/database/migrations/20251024092758_deleted-project_flock_id_in_kandangs.up.sql new file mode 100644 index 00000000..14e6dd0a --- /dev/null +++ b/internal/database/migrations/20251024092758_deleted-project_flock_id_in_kandangs.up.sql @@ -0,0 +1,22 @@ + +ALTER TABLE kandangs + DROP CONSTRAINT IF EXISTS kandangs_project_flock_id_fkey; + +ALTER TABLE kandangs + DROP COLUMN IF EXISTS project_flock_id; + +ALTER TABLE project_chickins + DROP CONSTRAINT fk_project_flock_kandang_id, + ADD CONSTRAINT fk_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON UPDATE CASCADE + ON DELETE CASCADE; + +ALTER TABLE project_flock_populations + DROP CONSTRAINT fk_project_flock_kandang_id, + ADD CONSTRAINT fk_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON UPDATE CASCADE + ON DELETE CASCADE; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 791cfddb..0ce6452b 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -51,12 +51,12 @@ func Run(db *gorm.DB) error { return err } - projectFlocks, err := seedProjectFlocks(tx, adminID, flocks, areas, fcrs, locations) - if err != nil { + + if err := seedProjectFlocks(tx, adminID, flocks, areas, fcrs, locations); err != nil { return err } - - kandangs, err := seedKandangs(tx, adminID, locations, users, projectFlocks) + + kandangs, err := seedKandangs(tx, adminID, locations, users) if err != nil { return err } @@ -243,7 +243,11 @@ func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, fcrs, locations map[string]uint) (map[string]uint, error) { +func seedProjectFlocks( + tx *gorm.DB, + createdBy uint, + flocks, areas, fcrs, locations map[string]uint, +) error { seeds := []struct { Key string Flock string @@ -273,29 +277,30 @@ func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, fcrs, locatio }, } - result := make(map[string]uint, len(seeds)) - for _, seed := range seeds { flockID, ok := flocks[seed.Flock] if !ok { - return nil, fmt.Errorf("floc %s not seeded", seed.Flock) + return fmt.Errorf("floc %s not seeded", seed.Flock) } areaID, ok := areas[seed.Area] if !ok { - return nil, fmt.Errorf("area %s not seeded", seed.Area) + return fmt.Errorf("area %s not seeded", seed.Area) } fcrID, ok := fcrs[seed.Fcr] if !ok { - return nil, fmt.Errorf("fcr %s not seeded", seed.Fcr) + return fmt.Errorf("fcr %s not seeded", seed.Fcr) } locationID, ok := locations[seed.Location] if !ok { - return nil, fmt.Errorf("location %s not seeded", seed.Location) + return fmt.Errorf("location %s not seeded", seed.Location) } var projectFlock entity.ProjectFlock - err := tx.Where("flock_id = ? AND area_id = ? AND category = ? AND fcr_id = ? AND location_id = ? AND period = ?", - flockID, areaID, seed.Category, fcrID, locationID, seed.Period).First(&projectFlock).Error + err := tx.Where( + "flock_id = ? AND area_id = ? AND category = ? AND fcr_id = ? AND location_id = ? AND period = ?", + flockID, areaID, seed.Category, fcrID, locationID, seed.Period, + ).First(&projectFlock).Error + if errors.Is(err, gorm.ErrRecordNotFound) { projectFlock = entity.ProjectFlock{ FlockId: flockID, @@ -307,10 +312,10 @@ func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, fcrs, locatio CreatedBy: createdBy, } if err := tx.Create(&projectFlock).Error; err != nil { - return nil, err + return err } } else if err != nil { - return nil, err + return err } else { if err := tx.Model(&entity.ProjectFlock{}).Where("id = ?", projectFlock.Id).Updates(map[string]any{ "flock_id": flockID, @@ -320,17 +325,16 @@ func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, fcrs, locatio "location_id": locationID, "period": seed.Period, }).Error; err != nil { - return nil, err + return err } } if err := ensureProjectFlockApprovals(tx, projectFlock.Id, createdBy); err != nil { - return nil, err + return err } - result[seed.Key] = projectFlock.Id } - return result, nil + return nil } func ensureProjectFlockApprovals(tx *gorm.DB, projectFlockID uint, actorID uint) error { @@ -385,17 +389,16 @@ func ensureProjectFlockApprovals(tx *gorm.DB, projectFlockID uint, actorID uint) return nil } -func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint, projectFlocks map[string]uint) (map[string]uint, error) { +func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { seeds := []struct { Name string Status utils.KandangStatus Location string PicKey string - ProjectFlockKey *string }{ - {Name: "Singaparna 1", Status: utils.KandangStatusActive, Location: "Singaparna", PicKey: "admin", ProjectFlockKey: strPtr("Singaparna Period 1")}, + {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, - {Name: "Cikaum 1", Status: utils.KandangStatusActive, Location: "Cikaum", PicKey: "admin", ProjectFlockKey: strPtr("Cikaum Period 1")}, + {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, } @@ -411,14 +414,6 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return nil, fmt.Errorf("user %s not seeded", seed.PicKey) } - var projectFlockID *uint - if seed.ProjectFlockKey != nil { - pfID, ok := projectFlocks[*seed.ProjectFlockKey] - if !ok { - return nil, fmt.Errorf("project flock %s not seeded", *seed.ProjectFlockKey) - } - projectFlockID = uintPtr(pfID) - } var kandang entity.Kandang err := tx.Where("name = ?", seed.Name).First(&kandang).Error @@ -428,15 +423,11 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users Status: string(seed.Status), LocationId: locID, PicId: picID, - ProjectFlockId: projectFlockID, CreatedBy: createdBy, } if err := tx.Create(&kandang).Error; err != nil { return nil, err } - if err := syncPivotRelation(tx, projectFlockID, kandang.Id); err != nil { - return nil, err - } } else if err != nil { return nil, err } else { @@ -445,17 +436,9 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users "pic_id": picID, "status": string(seed.Status), } - if projectFlockID != nil { - updates["project_flock_id"] = *projectFlockID - } else { - updates["project_flock_id"] = nil - } 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); err != nil { - return nil, err - } } result[seed.Name] = kandang.Id } @@ -463,37 +446,6 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return result, nil } -func syncPivotRelation(tx *gorm.DB, projectFlockID *uint, kandangID uint) error { - if err := detachActivePivot(tx, kandangID); err != nil { - return err - } - if projectFlockID == nil { - return nil - } - return ensureActivePivot(tx, *projectFlockID, kandangID) -} - -func detachActivePivot(tx *gorm.DB, kandangID uint) error { - return tx.Where("kandang_id = ?", kandangID). - Delete(&entity.ProjectFlockKandang{}).Error -} - -func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID uint) error { - var pivot entity.ProjectFlockKandang - err := tx.Where("project_flock_id = ? AND kandang_id = ?", 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, - } - 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 { @@ -1133,153 +1085,71 @@ func seedTransferStock(tx *gorm.DB, createdBy uint) error { return nil } - func seedChickin(tx *gorm.DB, createdBy uint) error { - seeds := []struct { - ProjectFlockKandangId uint - ChickInDate string - Quantity float64 - Note string - }{ - {ProjectFlockKandangId: 1, ChickInDate: "2025-10-20", Quantity: 100, Note: "Seeder chickin 1"}, - {ProjectFlockKandangId: 2, ChickInDate: "2025-10-21", Quantity: 200, Note: "Seeder chickin 2"}, - } + // gunakan identitas yang stabil, bukan ID pivot + seeds := []struct { + KandangName string + LocationName string + Period int + ChickInDate string + Quantity float64 + Note string + }{ + {"Singaparna 1", "Singaparna", 1, "2025-10-20", 100, "Seeder chickin 1"}, + {"Cikaum 1", "Cikaum", 1, "2025-10-21", 200, "Seeder chickin 2"}, + } - for _, seed := range seeds { - chickinDate, err := time.Parse("2006-01-02", seed.ChickInDate) - if err != nil { - return err - } + for _, s := range seeds { + pfkID, err := ensurePFK(tx, s.KandangName, s.LocationName, s.Period) + if err != nil { return err } - // Insert ProjectChickin jika belum ada - var chickin entity.ProjectChickin - err = tx.Where("project_flock_kandang_id = ? AND chick_in_date = ?", seed.ProjectFlockKandangId, chickinDate). - First(&chickin).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - chickin = entity.ProjectChickin{ - ProjectFlockKandangId: seed.ProjectFlockKandangId, - ChickInDate: chickinDate, - Quantity: seed.Quantity, - Note: seed.Note, - CreatedBy: createdBy, - } - if err := tx.Create(&chickin).Error; err != nil { - return err - } - } else if err != nil { - return err - } + date, err := time.Parse("2006-01-02", s.ChickInDate) + if err != nil { return err } - var population entity.ProjectFlockPopulation - err = tx.Where("project_flock_kandang_id = ?", seed.ProjectFlockKandangId).First(&population).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - population = entity.ProjectFlockPopulation{ - ProjectFlockKandangId: seed.ProjectFlockKandangId, - InitialQuantity: seed.Quantity, - CurrentQuantity: seed.Quantity, - ReservedQuantity: 0, - CreatedBy: createdBy, - } - if err := tx.Create(&population).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - // Update population quantities - if err := tx.Model(&entity.ProjectFlockPopulation{}). - Where("id = ?", population.Id). - Updates(map[string]any{ - "initial_quantity": population.InitialQuantity + seed.Quantity, - "current_quantity": population.CurrentQuantity + seed.Quantity, - "reserved_quantity": 0, - }).Error; err != nil { - return err - } - } + // upsert project_chickin (idempotent) + var chickin entity.ProjectChickin + err = tx.Where("project_flock_kandang_id = ? AND chick_in_date = ?", pfkID, date).First(&chickin).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + chickin = entity.ProjectChickin{ + ProjectFlockKandangId: pfkID, + ChickInDate: date, + Quantity: s.Quantity, + Note: s.Note, + CreatedBy: createdBy, + } + if err := tx.Create(&chickin).Error; err != nil { return err } + } else if err != nil { + return err + } - var pfk entity.ProjectFlockKandang - if err := tx.Where("id = ?", seed.ProjectFlockKandangId).First(&pfk).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - // no pivot found; skip creating details - continue - } - return err - } - - var warehouse entity.Warehouse - if err := tx.Where("kandang_id = ?", pfk.KandangId).First(&warehouse).Error; err != nil { - // if warehouse not found, cannot create details - if errors.Is(err, gorm.ErrRecordNotFound) { - continue - } - return err - } - - var productWarehouses []entity.ProductWarehouse - err = tx.Table("product_warehouses"). - Select("product_warehouses.*"). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). - Order("product_warehouses.created_at DESC"). - Find(&productWarehouses).Error - if err != nil { - return err - } - - // If no product warehouses found, keep existing chickin.Quantity and skip details - if len(productWarehouses) == 0 { - continue - } - - // sum all pw quantities and set chickin.Quantity to that total (mimic CreateOne) - totalQty := 0.0 - for _, pw := range productWarehouses { - totalQty += pw.Quantity - } - - if chickin.Quantity != totalQty { - if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Update("quantity", totalQty).Error; err != nil { - return err - } - chickin.Quantity = totalQty - } - - for _, pw := range productWarehouses { - // ensure detail exists or create it with full pw.Quantity - var detail entity.ProjectChickinDetail - err = tx.Where("project_chickin_id = ? AND product_warehouse_id = ?", chickin.Id, pw.Id).First(&detail).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - detail = entity.ProjectChickinDetail{ - ProjectChickinId: chickin.Id, - ProductWarehouseId: pw.Id, - Quantity: pw.Quantity, - CreatedBy: createdBy, - } - if err := tx.Create(&detail).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - if detail.Quantity != pw.Quantity { - if err := tx.Model(&entity.ProjectChickinDetail{}).Where("id = ?", detail.Id).Update("quantity", pw.Quantity).Error; err != nil { - return err - } - } - } - - // zero out pw quantity - if err := tx.Model(&entity.ProductWarehouse{}).Where("id = ?", pw.Id).Update("quantity", 0).Error; err != nil { - return err - } - } - } - - return nil + // upsert population + var pop entity.ProjectFlockPopulation + err = tx.Where("project_flock_kandang_id = ?", pfkID).First(&pop).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + pop = entity.ProjectFlockPopulation{ + ProjectFlockKandangId: pfkID, + InitialQuantity: s.Quantity, + CurrentQuantity: s.Quantity, + ReservedQuantity: 0, + CreatedBy: createdBy, + } + if err := tx.Create(&pop).Error; err != nil { return err } + } else if err != nil { + return err + } else { + if err := tx.Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", pop.Id). + Updates(map[string]any{ + "initial_quantity": pop.InitialQuantity + s.Quantity, + "current_quantity": pop.CurrentQuantity + s.Quantity, + "reserved_quantity": 0, + }).Error; err != nil { return err } + } + } + return nil } + func ptr[T any](v T) *T { return &v } @@ -1295,3 +1165,30 @@ func intPtr(v int) *int { func uintPtr(v uint) *uint { return &v } + +func ensurePFK(tx *gorm.DB, kandangName, locationName string, period int) (uint, error) { + var kandang entity.Kandang + if err := tx.Where("name = ?", kandangName).First(&kandang).Error; err != nil { + return 0, fmt.Errorf("kandang %q not found: %w", kandangName, err) + } + var loc entity.Location + if err := tx.Where("name = ?", locationName).First(&loc).Error; err != nil { + return 0, fmt.Errorf("location %q not found: %w", locationName, err) + } + var pf entity.ProjectFlock + if err := tx.Where("location_id = ? AND period = ?", loc.Id, period).First(&pf).Error; err != nil { + return 0, fmt.Errorf("project_flock for %s period %d not found: %w", locationName, period, err) + } + var pfk entity.ProjectFlockKandang + if err := tx.Where("project_flock_id = ? AND kandang_id = ?", pf.Id, kandang.Id).First(&pfk).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + pfk = entity.ProjectFlockKandang{ ProjectFlockId: pf.Id, KandangId: kandang.Id } + if err := tx.Create(&pfk).Error; err != nil { + return 0, fmt.Errorf("create pivot pfk(%d,%d) failed: %w", pf.Id, kandang.Id, err) + } + } else { + return 0, err + } + } + return pfk.Id, nil +} diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index c71382da..178681f0 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -7,18 +7,17 @@ import ( ) type Kandang struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` - Status string `gorm:"type:varchar(50);not null"` - LocationId uint `gorm:"not null"` - PicId uint `gorm:"not null"` - ProjectFlockId *uint `gorm:"column:project_flock_id"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Location Location `gorm:"foreignKey:LocationId;references:Id"` - Pic User `gorm:"foreignKey:PicId;references:Id"` - ProjectFlock *ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` + Status string `gorm:"type:varchar(50);not null"` + LocationId uint `gorm:"not null"` + PicId uint `gorm:"not null"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + Pic User `gorm:"foreignKey:PicId;references:Id"` + ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"` } diff --git a/internal/entities/project_chickin.go b/internal/entities/project_chickin.go index 95a658c8..5dd22f1a 100644 --- a/internal/entities/project_chickin.go +++ b/internal/entities/project_chickin.go @@ -10,7 +10,7 @@ const () type ProjectChickin struct { Id uint `gorm:"primaryKey"` - ProjectFlockKandangId uint `gorm:"not null"` + ProjectFlockKandangId uint `gorm:"not null;index;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` ChickInDate time.Time `gorm:"not null"` Quantity float64 `gorm:"not null"` Note string `gorm:"type:text"` diff --git a/internal/entities/project_flock_population.go b/internal/entities/project_flock_population.go index 184ace65..6cd3a214 100644 --- a/internal/entities/project_flock_population.go +++ b/internal/entities/project_flock_population.go @@ -8,7 +8,7 @@ import ( type ProjectFlockPopulation struct { Id uint `gorm:"primaryKey"` - ProjectFlockKandangId uint `gorm:"not null"` + ProjectFlockKandangId uint `gorm:"not null;index;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` InitialQuantity float64 `gorm:"type:numeric(15,3);not null"` CurrentQuantity float64 `gorm:"type:numeric(15,3);not null"` ReservedQuantity float64 `gorm:"type:numeric(15,3)"` @@ -18,5 +18,6 @@ type ProjectFlockPopulation struct { DeletedAt gorm.DeletedAt `gorm:"index"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go index c840892f..e734743c 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -24,7 +24,8 @@ type ProjectFlock struct { 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"` - LatestApproval *Approval `gorm:"-" json:"-"` + Kandangs []Kandang `gorm:"many2many:project_flock_kandangs;joinTableForeignKey:project_flock_id;joinTableReferences:kandang_id" json:"kandangs,omitempty"` + KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"` + + LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go index 1c29c22e..26238980 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -7,6 +7,9 @@ type ProjectFlockKandang struct { ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` CreatedAt time.Time `gorm:"autoCreateTime"` - ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` - Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` + + + ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` + Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` + } diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go index 22546339..b4351397 100644 --- a/internal/modules/master/kandangs/repositories/kandang.repository.go +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -18,6 +19,8 @@ type KandangRepository interface { GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error + UpsertProjectFlockKandang(ctx context.Context, projectFlockID, kandangID uint) error + } type KandangRepositoryImpl struct { @@ -58,14 +61,15 @@ func (r *KandangRepositoryImpl) ProjectFlockExists(ctx context.Context, projectF func (r *KandangRepositoryImpl) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) { var count int64 - q := r.db.WithContext(ctx). - Model(&entity.Kandang{}). - Where("project_flock_id = ?", projectFlockID). - Where("status = ?", utils.KandangStatusActive). - Where("deleted_at IS NULL") - if excludeID != nil { - q = q.Where("id <> ?", *excludeID) - } + q := r.db.WithContext(ctx). + Table("kandangs k"). + Joins("JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id"). + Where("pfk.project_flock_id = ?", projectFlockID). + Where("k.status = ?", utils.KandangStatusActive). + Where("k.deleted_at IS NULL") + if excludeID != nil { + q = q.Where("k.id <> ?", *excludeID) + } if err := q.Count(&count).Error; err != nil { return false, err } @@ -74,18 +78,49 @@ func (r *KandangRepositoryImpl) HasActiveKandangForProjectFlock(ctx context.Cont func (r *KandangRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error) { kandang := new(entity.Kandang) - err := r.db.WithContext(ctx). - Where("project_flock_id = ?", projectFlockID). - First(kandang).Error - if err != nil { - return nil, err - } + err := r.db.WithContext(ctx). + Table("kandangs k"). + Joins("JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id"). + Where("pfk.project_flock_id = ?", projectFlockID). + Where("k.deleted_at IS NULL"). + Order("k.id ASC"). + Limit(1). + Find(kandang).Error + if err != nil { + return nil, err + } + if kandang.Id == 0 { + return nil, gorm.ErrRecordNotFound + } + return kandang, nil } func (r *KandangRepositoryImpl) UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error { - return r.db.WithContext(ctx). - Model(&entity.Kandang{}). - Where("project_flock_id = ?", projectFlockID). - Update("status", string(status)).Error + sub := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Select("kandang_id"). + Where("project_flock_id = ?", projectFlockID) + + return r.db.WithContext(ctx). + Model(&entity.Kandang{}). + Where("id IN (?)", sub). + Where("deleted_at IS NULL"). + Update("status", string(status)).Error } + +func (r *KandangRepositoryImpl) UpsertProjectFlockKandang(ctx context.Context, projectFlockID, kandangID uint) error { + var link entity.ProjectFlockKandang + err := r.db.WithContext(ctx). + Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). + First(&link).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + link = entity.ProjectFlockKandang{ + ProjectFlockId: projectFlockID, + KandangId: kandangID, + } + return r.db.WithContext(ctx).Create(&link).Error + } + return err +} + diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index 6e836170..9cad90f3 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -40,7 +40,8 @@ func NewKandangService(repo repository.KandangRepository, validate *validator.Va } func (s kandangService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("CreatedUser").Preload("Location").Preload("Pic") + return db.Preload("CreatedUser").Preload("Location").Preload("Pic").Preload("ProjectFlockKandangs.ProjectFlock") + } func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Kandang, int64, error) { @@ -110,7 +111,6 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang status") } - var projectFlockID *uint if req.ProjectFlockId != nil { if exists, err := s.Repository.ProjectFlockExists(c.Context(), *req.ProjectFlockId); err != nil { s.Log.Errorf("Failed to check project flock existence: %+v", err) @@ -128,8 +128,6 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } } - idCopy := *req.ProjectFlockId - projectFlockID = &idCopy } //TODO: created by dummy @@ -138,7 +136,6 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit LocationId: req.LocationId, Status: status, PicId: req.PicId, - ProjectFlockId: projectFlockID, CreatedBy: 1, } @@ -147,6 +144,12 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } + if req.ProjectFlockId != nil { + if err := s.Repository.UpsertProjectFlockKandang(c.Context(), *req.ProjectFlockId, createBody.Id); err != nil { + s.Log.Errorf("Failed to link kandang to project_flock via pivot: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to link kandang to project flock") + } + } return s.GetOne(c, createBody.Id) } @@ -201,7 +204,6 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) finalStatus = status } - projectFlockIDToUse := existing.ProjectFlockId if req.ProjectFlockId != nil { if exists, err := s.Repository.ProjectFlockExists(c.Context(), *req.ProjectFlockId); err != nil { s.Log.Errorf("Failed to check project flock existence: %+v", err) @@ -209,30 +211,33 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } else if !exists { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Project flock with id %d not found", *req.ProjectFlockId)) } - idCopy := *req.ProjectFlockId - projectFlockIDToUse = &idCopy - updateBody["project_flock_id"] = idCopy - } - if projectFlockIDToUse != nil && finalStatus == string(utils.KandangStatusActive) { - if active, err := s.Repository.HasActiveKandangForProjectFlock(c.Context(), *projectFlockIDToUse, &id); err != nil { - s.Log.Errorf("Failed to check kandang activity for project flock %d: %+v", *projectFlockIDToUse, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check active kandang for project flock") - } else if active { - return nil, fiber.NewError(fiber.StatusConflict, "Project flock already has an active kandang") + // Kalau status jadi ACTIVE, pastikan tidak ada kandang aktif lain pada project flock tsb (hitung via pivot) + if finalStatus == string(utils.KandangStatusActive) { + if active, err := s.Repository.HasActiveKandangForProjectFlock(c.Context(), *req.ProjectFlockId, &id); err != nil { + s.Log.Errorf("Failed to check kandang activity for project flock %d: %+v", *req.ProjectFlockId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check active kandang for project flock") + } else if active { + return nil, fiber.NewError(fiber.StatusConflict, "Project flock already has an active kandang") + } } } - if len(updateBody) == 0 { - return s.GetOne(c, id) + if len(updateBody) > 0 { + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + s.Log.Errorf("Failed to update kandang: %+v", err) + return nil, err + } } - if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") + if req.ProjectFlockId != nil { + if err := s.Repository.UpsertProjectFlockKandang(c.Context(), *req.ProjectFlockId, id); err != nil { + s.Log.Errorf("Failed to upsert pivot kandang-project_flock: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to link kandang to project flock") } - s.Log.Errorf("Failed to update kandang: %+v", err) - return nil, err } return s.GetOne(c, id) diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index 4c11d3a1..38f14bb0 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -28,4 +28,5 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) route.Get("/flocks/:flock_id/periods", ctrl.GetFlockPeriodSummary) + } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index f9c7881e..aeef6474 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -107,9 +107,14 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e 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) + db = db.Where(` + EXISTS ( + SELECT 1 + FROM project_flock_kandangs pfk + WHERE pfk.project_flock_id = project_flocks.id + AND pfk.kandang_id IN ? + )`, params.KandangIds) } - if params.Search != "" { normalizedSearch := strings.ToLower(strings.TrimSpace(params.Search)) if normalizedSearch == "" { @@ -250,10 +255,11 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* 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)) - } + // larang kalau ada yg sudah terikat ke project lain + if linked, err := s.anyKandangLinkedToOtherProject(c.Context(), s.Repository.DB(), kandangIDs, nil); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") + } else if linked { + return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") } createBody := &entity.ProjectFlock{ @@ -394,11 +400,12 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id 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)) - } + if linked, err := s.anyKandangLinkedToOtherProject(c.Context(), s.Repository.DB(), newKandangIDs, &id); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") + } else if linked { + return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") } + } hasChanges := hasBodyChanges || hasKandangChanges @@ -754,7 +761,7 @@ func (s projectflockService) buildOrderExpressions(sortBy, sortOrder string) []s } case "kandangs": return []string{ - fmt.Sprintf("(SELECT COUNT(*) FROM kandangs WHERE kandangs.project_flock_id = project_flocks.id) %s", direction), + fmt.Sprintf("(SELECT COUNT(*) FROM project_flock_kandangs pfk WHERE pfk.project_flock_id = project_flocks.id) %s", direction), fmt.Sprintf("project_flocks.id %s", direction), } case "period": @@ -775,24 +782,50 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * return nil } - if err := dbTransaction.Model(&entity.Kandang{}). + if err := dbTransaction. + Model(&entity.Kandang{}). Where("id IN ?", kandangIDs). Updates(map[string]any{ - "project_flock_id": projectFlockID, - "status": string(utils.KandangStatusPengajuan), + "status": string(utils.KandangStatusPengajuan), }).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") } - pivotRepo := s.pivotRepoWithTx(dbTransaction) - records := make([]*entity.ProjectFlockKandang, len(kandangIDs)) - for i, id := range kandangIDs { - records[i] = &entity.ProjectFlockKandang{ - ProjectFlockId: projectFlockID, - KandangId: id, + var already []uint + if err := dbTransaction. + Table("project_flock_kandangs"). + Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs). + Pluck("kandang_id", &already).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing pivot") + } + exists := make(map[uint]struct{}, len(already)) + for _, id := range already { + exists[id] = struct{}{} + } + + var toAttach []uint + seen := make(map[uint]struct{}, len(kandangIDs)) + for _, id := range kandangIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + if _, ok := exists[id]; !ok { + toAttach = append(toAttach, id) } } - if err := pivotRepo.CreateMany(ctx, records); err != nil { + if len(toAttach) == 0 { + return nil + } + + records := make([]*entity.ProjectFlockKandang, 0, len(toAttach)) + for _, id := range toAttach { + records = append(records, &entity.ProjectFlockKandang{ + ProjectFlockId: projectFlockID, + KandangId: id, + }) + } + if err := s.pivotRepoWithTx(dbTransaction).CreateMany(ctx, records); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil @@ -803,15 +836,15 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return nil } - updates := map[string]any{"project_flock_id": nil} if resetStatus { - updates["status"] = string(utils.KandangStatusNonActive) - } - - if err := dbTransaction.Model(&entity.Kandang{}). - Where("id IN ?", kandangIDs). - Updates(updates).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + if err := dbTransaction. + Model(&entity.Kandang{}). + Where("id IN ?", kandangIDs). + Updates(map[string]any{ + "status": string(utils.KandangStatusNonActive), + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") + } } if err := s.pivotRepoWithTx(dbTransaction).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { @@ -820,9 +853,24 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return nil } + func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { if s.PivotRepo == nil { return repository.NewProjectFlockKandangRepository(dbTransaction) } return s.PivotRepo.WithTx(dbTransaction) } + +func (s projectflockService) anyKandangLinkedToOtherProject(ctx context.Context, db *gorm.DB, kandangIDs []uint, exceptProjectID *uint) (bool, error) { + q := db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("kandang_id IN ?", kandangIDs) + if exceptProjectID != nil { + q = q.Where("project_flock_id <> ?", *exceptProjectID) + } + var count int64 + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 8dd114d1..85f79011 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -1,13 +1,38 @@ package repository import ( - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "errors" + "math" + "sort" + "strings" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" ) type RecordingRepository interface { repository.BaseRepository[entity.Recording] + + WithRelations(db *gorm.DB) *gorm.DB + GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) + + CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error + DeleteBodyWeights(tx *gorm.DB, recordingID uint) error + + CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error + DeleteStocks(tx *gorm.DB, recordingID uint) error + + CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error + DeleteDepletions(tx *gorm.DB, recordingID uint) error + + SumRecordingDepletions(tx *gorm.DB, recordingID uint) (int64, error) + FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) + GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) + GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) + GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) + GetFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) + GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) } type RecordingRepositoryImpl struct { @@ -19,3 +44,235 @@ func NewRecordingRepository(db *gorm.DB) RecordingRepository { BaseRepositoryImpl: repository.NewBaseRepository[entity.Recording](db), } } + +func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock"). + Preload("BodyWeights"). + Preload("Depletions"). + Preload("Depletions.ProductWarehouse"). + Preload("Depletions.ProductWarehouse.Product"). + Preload("Depletions.ProductWarehouse.Warehouse"). + Preload("Stocks"). + Preload("Stocks.ProductWarehouse"). + Preload("Stocks.ProductWarehouse.Product"). + Preload("Stocks.ProductWarehouse.Warehouse") +} + +func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { + var days []int + if err := tx.Model(&entity.Recording{}). + Where("project_flock_id = ?", projectFlockKandangId). + Where("day IS NOT NULL"). + Pluck("day", &days).Error; err != nil { + return 0, err + } + return nextRecordingDay(days), nil +} + +func (r *RecordingRepositoryImpl) CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error { + if len(bodyWeights) == 0 { + return nil + } + return tx.Create(&bodyWeights).Error +} + +func (r *RecordingRepositoryImpl) DeleteBodyWeights(tx *gorm.DB, recordingID uint) error { + return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingBW{}).Error +} + +func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error { + if len(stocks) == 0 { + return nil + } + return tx.Create(&stocks).Error +} + +func (r *RecordingRepositoryImpl) DeleteStocks(tx *gorm.DB, recordingID uint) error { + return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error +} + +func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { + if len(depletions) == 0 { + return nil + } + return tx.Create(&depletions).Error +} + +func (r *RecordingRepositoryImpl) DeleteDepletions(tx *gorm.DB, recordingID uint) error { + return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingDepletion{}).Error +} + +func (r *RecordingRepositoryImpl) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (int64, error) { + var result int64 + if err := tx.Model(&entity.RecordingDepletion{}). + Where("recording_id = ?", recordingID). + Select("COALESCE(SUM(total), 0)"). + Scan(&result).Error; err != nil { + return 0, err + } + return result, nil +} + +func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) { + if currentDay <= 1 { + return nil, nil + } + + var prev entity.Recording + err := tx. + Where("project_flock_id = ? AND day < ?", projectFlockKandangId, currentDay). + Where("day IS NOT NULL"). + Order("day DESC"). + Limit(1). + Find(&prev).Error + + if errors.Is(err, gorm.ErrRecordNotFound) || prev.Id == 0 { + return nil, nil + } + if err != nil { + return nil, err + } + return &prev, nil +} + +func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) { + var population entity.ProjectFlockPopulation + err := tx. + Where("project_flock_kandang_id = ?", projectFlockKandangId). + Order("created_at DESC"). + First(&population).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + return int64(math.Round(population.InitialQuantity)), nil +} + +func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) { + var result struct { + TotalWeight float64 + TotalQty float64 + } + if err := tx.Model(&entity.RecordingBW{}). + Select("COALESCE(SUM(weight * qty), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty"). + Where("recording_id = ?", recordingID). + Scan(&result).Error; err != nil { + return 0, err + } + if result.TotalQty == 0 { + return 0, nil + } + return result.TotalWeight / result.TotalQty, nil +} + +func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { + var rows []struct { + UsageAmount float64 + UomName string + } + + if err := tx. + Table("recording_stocks"). + Select("COALESCE(recording_stocks.usage_amount, 0) AS usage_amount, LOWER(uoms.name) AS uom_name"). + Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN uoms ON uoms.id = products.uom_id"). + Where("recording_stocks.recording_id = ?", recordingID). + Scan(&rows).Error; err != nil { + return 0, err + } + + var total float64 + for _, row := range rows { + if row.UsageAmount <= 0 { + continue + } + switch strings.TrimSpace(row.UomName) { + case "kilogram", "kg", "kilograms", "kilo": + total += row.UsageAmount * 1000 + case "gram", "g", "grams": + total += row.UsageAmount + default: + total += row.UsageAmount + } + } + return total, nil +} + +func (r *RecordingRepositoryImpl) GetFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) { + var result struct { + FcrID uint + } + if err := tx.Table("project_flock_kandangs"). + Select("project_flocks.fcr_id AS fcr_id"). + Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). + Where("project_flock_kandangs.id = ?", projectFlockKandangId). + Scan(&result).Error; err != nil { + return 0, err + } + return result.FcrID, nil +} + +func (r *RecordingRepositoryImpl) GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) { + if fcrId == 0 { + return 0, false, nil + } + + var standard entity.FcrStandard + err := tx. + Where("fcr_id = ? AND weight >= ?", fcrId, currentWeightKg). + Order("weight ASC"). + First(&standard).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + err = tx. + Where("fcr_id = ?", fcrId). + Order("weight DESC"). + First(&standard).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, false, nil + } + } + if err != nil { + return 0, false, err + } + + weight := standard.Weight + if weight > 10 { + return weight / 1000, true, nil + } + return weight, true, nil +} + +func nextRecordingDay(days []int) int { + if len(days) == 0 { + return 1 + } + + unique := make(map[int]struct{}, len(days)) + for _, day := range days { + if day > 0 { + unique[day] = struct{}{} + } + } + + normalized := make([]int, 0, len(unique)) + for day := range unique { + normalized = append(normalized, day) + } + sort.Ints(normalized) + + for idx, day := range normalized { + expected := idx + 1 + if day != expected { + return expected + } + } + + return len(normalized) + 1 +} diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 38e46f37..a5238ff7 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -4,8 +4,6 @@ import ( "errors" "fmt" "math" - "sort" - "strings" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -53,22 +51,6 @@ func NewRecordingService( } } -func (s recordingService) withRelations(db *gorm.DB) *gorm.DB { - return db. - Preload("CreatedUser"). - Preload("ProjectFlockKandang"). - Preload("ProjectFlockKandang.ProjectFlock"). - Preload("BodyWeights"). - Preload("Depletions"). - Preload("Depletions.ProductWarehouse"). - Preload("Depletions.ProductWarehouse.Product"). - Preload("Depletions.ProductWarehouse.Warehouse"). - Preload("Stocks"). - Preload("Stocks.ProductWarehouse"). - Preload("Stocks.ProductWarehouse.Product"). - Preload("Stocks.ProductWarehouse.Warehouse") -} - func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -85,7 +67,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti offset := (page - 1) * limit recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) + db = s.Repository.WithRelations(db) if params.ProjectFlockKandangId != 0 { db = db.Where("project_flock_id = ?", params.ProjectFlockKandangId) } @@ -100,7 +82,9 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { - recording, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + recording, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(db) + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") } @@ -117,7 +101,7 @@ func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) ( } db := s.Repository.DB().WithContext(c.Context()) - next, err := s.generateNextDay(db, projectFlockKandangId) + next, err := s.Repository.GenerateNextDay(db, projectFlockKandangId) if err != nil { s.Log.Errorf("Failed to compute next recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err) return 0, err @@ -155,7 +139,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } }() - nextDay, err := s.generateNextDay(tx, req.ProjectFlockKandangId) + nextDay, err := s.Repository.GenerateNextDay(tx, req.ProjectFlockKandangId) if err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to determine recording day: %+v", err) @@ -184,21 +168,25 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := tx.Create(recording).Error; err != nil { _ = tx.Rollback() + if errors.Is(err, gorm.ErrDuplicatedKey) { + dateStr := recordDate.Format("2006-01-02") + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Recording for project flock %d on %s already exists", req.ProjectFlockKandangId, dateStr)) + } s.Log.Errorf("Failed to create recording: %+v", err) return nil, err } - if err := s.persistBodyWeights(tx, recording.Id, req.BodyWeights); err != nil { + if err := s.Repository.CreateBodyWeights(tx, mapBodyWeights(recording.Id, req.BodyWeights)); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to persist body weights: %+v", err) return nil, err } - if err := s.persistStocks(tx, recording.Id, req.Stocks); err != nil { + if err := s.Repository.CreateStocks(tx, mapStocks(recording.Id, req.Stocks)); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to persist stocks: %+v", err) return nil, err } - if err := s.persistDepletions(tx, recording.Id, req.Depletions); err != nil { + if err := s.Repository.CreateDepletions(tx, mapDepletions(recording.Id, req.Depletions)); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to persist depletions: %+v", err) return nil, err @@ -254,7 +242,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin recording.Ontime = ontimeValue if req.BodyWeights != nil { - if err := s.replaceBodyWeights(tx, recording.Id, req.BodyWeights); err != nil { + if err := s.Repository.DeleteBodyWeights(tx, recording.Id); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to clear body weights: %+v", err) + return nil, err + } + if err := s.Repository.CreateBodyWeights(tx, mapBodyWeights(recording.Id, req.BodyWeights)); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to update body weights: %+v", err) return nil, err @@ -265,7 +258,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin _ = tx.Rollback() return nil, err } - if err := s.replaceStocks(tx, recording.Id, req.Stocks); err != nil { + if err := s.Repository.DeleteStocks(tx, recording.Id); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to clear stocks: %+v", err) + return nil, err + } + if err := s.Repository.CreateStocks(tx, mapStocks(recording.Id, req.Stocks)); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to update stocks: %+v", err) return nil, err @@ -276,7 +274,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin _ = tx.Rollback() return nil, err } - if err := s.replaceDepletions(tx, recording.Id, req.Depletions); err != nil { + if err := s.Repository.DeleteDepletions(tx, recording.Id); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to clear depletions: %+v", err) + return nil, err + } + if err := s.Repository.CreateDepletions(tx, mapDepletions(recording.Id, req.Depletions)); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to update depletions: %+v", err) return nil, err @@ -342,45 +345,6 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v return nil } -func (s *recordingService) generateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { - var days []int - if err := tx.Model(&entity.Recording{}). - Where("project_flock_id = ?", projectFlockKandangId). - Where("day IS NOT NULL"). - Pluck("day", &days).Error; err != nil { - return 0, err - } - return nextRecordingDay(days), nil -} - -func nextRecordingDay(days []int) int { - if len(days) == 0 { - return 1 - } - - unique := make(map[int]struct{}, len(days)) - for _, day := range days { - if day > 0 { - unique[day] = struct{}{} - } - } - - normalized := make([]int, 0, len(unique)) - for day := range unique { - normalized = append(normalized, day) - } - sort.Ints(normalized) - - for idx, day := range normalized { - expected := idx + 1 - if day != expected { - return expected - } - } - - return len(normalized) + 1 -} - func computeOntime(recordDatetime, reference time.Time) bool { return !recordDatetime.Before(reference) } @@ -392,107 +356,81 @@ func boolToInt(v bool) int { return 0 } -func (s *recordingService) persistBodyWeights(tx *gorm.DB, recordingID uint, payload []validation.BodyWeight) error { +func mapBodyWeights(recordingID uint, payload []validation.BodyWeight) []entity.RecordingBW { if len(payload) == 0 { return nil } - bodyWeights := make([]entity.RecordingBW, len(payload)) + items := make([]entity.RecordingBW, len(payload)) for i, bw := range payload { - bodyWeights[i] = entity.RecordingBW{ + items[i] = entity.RecordingBW{ RecordingId: recordingID, Weight: bw.Weight, Qty: bw.Qty, Notes: bw.Notes, } } - - return tx.Create(&bodyWeights).Error + return items } -func (s *recordingService) persistStocks(tx *gorm.DB, recordingID uint, payload []validation.Stock) error { +func mapStocks(recordingID uint, payload []validation.Stock) []entity.RecordingStock { if len(payload) == 0 { return nil } - stocks := make([]entity.RecordingStock, len(payload)) + items := make([]entity.RecordingStock, len(payload)) for i, stock := range payload { - stocks[i] = entity.RecordingStock{ + items[i] = entity.RecordingStock{ RecordingId: recordingID, ProductWarehouseId: stock.ProductWarehouseId, Notes: stock.Notes, } if stock.Increase != nil { val := *stock.Increase - stocks[i].Increase = &val + items[i].Increase = &val } if stock.Decrease != nil { val := *stock.Decrease - stocks[i].Decrease = &val + items[i].Decrease = &val } if stock.UsageAmount != nil { val := *stock.UsageAmount - stocks[i].UsageAmount = &val + items[i].UsageAmount = &val } } - - return tx.Create(&stocks).Error + return items } -func (s *recordingService) persistDepletions(tx *gorm.DB, recordingID uint, payload []validation.Depletion) error { +func mapDepletions(recordingID uint, payload []validation.Depletion) []entity.RecordingDepletion { if len(payload) == 0 { return nil } - depletions := make([]entity.RecordingDepletion, len(payload)) - for i, depl := range payload { - total := depl.Total - depletions[i] = entity.RecordingDepletion{ + items := make([]entity.RecordingDepletion, len(payload)) + for i, dep := range payload { + total := dep.Total + items[i] = entity.RecordingDepletion{ RecordingId: recordingID, - ProductWarehouseId: depl.ProductWarehouseId, + ProductWarehouseId: dep.ProductWarehouseId, Total: total, - Notes: depl.Notes, + Notes: dep.Notes, } } - - return tx.Create(&depletions).Error + return items } -func (s *recordingService) replaceBodyWeights(tx *gorm.DB, recordingID uint, payload []validation.BodyWeight) error { - if err := tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingBW{}).Error; err != nil { - return err - } - return s.persistBodyWeights(tx, recordingID, payload) -} - -func (s *recordingService) replaceStocks(tx *gorm.DB, recordingID uint, payload []validation.Stock) error { - if err := tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error; err != nil { - return err - } - return s.persistStocks(tx, recordingID, payload) -} - -func (s *recordingService) replaceDepletions(tx *gorm.DB, recordingID uint, payload []validation.Depletion) error { - if err := tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingDepletion{}).Error; err != nil { - return err - } - return s.persistDepletions(tx, recordingID, payload) -} - -// === Metrics Calculation === - func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entity.Recording) error { day := 0 if recording.Day != nil { day = *recording.Day } - totalDepletion, err := s.sumRecordingDepletions(tx, recording.Id) + totalDepletion, err := s.Repository.SumRecordingDepletions(tx, recording.Id) if err != nil { return fmt.Errorf("sumRecordingDepletions: %w", err) } - prevRecording, err := s.getPreviousRecording(tx, recording.ProjectFlockKandangId, day) + prevRecording, err := s.Repository.FindPreviousRecording(tx, recording.ProjectFlockKandangId, day) if err != nil { return fmt.Errorf("getPreviousRecording: %w", err) } @@ -507,28 +445,28 @@ func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entit if prevRecording.CumIntake != nil { prevCumIntake = float64(*prevRecording.CumIntake) } - prevAvgWeight, err = s.getAverageBodyWeight(tx, prevRecording.Id) + prevAvgWeight, err = s.Repository.GetAverageBodyWeight(tx, prevRecording.Id) if err != nil { return fmt.Errorf("getAverageBodyWeight(prev): %w", err) } } - totalChick, err := s.getTotalChick(tx, recording.ProjectFlockKandangId) + totalChick, err := s.Repository.GetTotalChick(tx, recording.ProjectFlockKandangId) if err != nil { return fmt.Errorf("getTotalChick: %w", err) } - currentAvgWeight, err := s.getAverageBodyWeight(tx, recording.Id) + currentAvgWeight, err := s.Repository.GetAverageBodyWeight(tx, recording.Id) if err != nil { return fmt.Errorf("getAverageBodyWeight(current): %w", err) } - usageInGrams, err := s.getFeedUsageInGrams(tx, recording.Id) + usageInGrams, err := s.Repository.GetFeedUsageInGrams(tx, recording.Id) if err != nil { return fmt.Errorf("getFeedUsageInGrams: %w", err) } - fcrId, err := s.getFcrID(tx, recording.ProjectFlockKandangId) + fcrId, err := s.Repository.GetFcrID(tx, recording.ProjectFlockKandangId) if err != nil { return fmt.Errorf("getFcrID: %w", err) } @@ -551,11 +489,11 @@ func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entit if totalChick > 0 { remainingChick := totalChick - cumDepletion - if remainingChick < 0 { - remainingChick = 0 - } - updates["total_chick"] = remainingChick - recording.TotalChick = &remainingChick + if remainingChick < 0 { + remainingChick = 0 + } + updates["total_chick"] = remainingChick + recording.TotalChick = &remainingChick cumRate := (float64(cumDepletion) / float64(totalChick)) * 100 updates["cum_depletion_rate"] = cumRate @@ -587,7 +525,7 @@ func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entit } if fcrId != 0 && currentAvgKg > 0 && day > 0 { - if fcrWeightKg, ok, err := s.getFcrStandardWeightKg(tx, fcrId, currentAvgKg); err != nil { + if fcrWeightKg, ok, err := s.Repository.GetFcrStandardWeightKg(tx, fcrId, currentAvgKg); err != nil { return fmt.Errorf("getFcrStandardWeightKg: %w", err) } else if ok { avgDailyGain := (currentAvgKg - fcrWeightKg) / float64(day) @@ -644,153 +582,6 @@ func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entit return nil } -// === Query Helpers === - -func (s *recordingService) sumRecordingDepletions(tx *gorm.DB, recordingID uint) (int64, error) { - var result int64 - if err := tx.Model(&entity.RecordingDepletion{}). - Where("recording_id = ?", recordingID). - Select("COALESCE(SUM(total), 0)"). - Scan(&result).Error; err != nil { - return 0, err - } - return result, nil -} - -func (s *recordingService) getPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) { - if currentDay <= 1 { - return nil, nil - } - - var prev entity.Recording - err := tx. - Where("project_flock_id = ? AND day < ?", projectFlockKandangId, currentDay). - Where("day IS NOT NULL"). - Order("day DESC"). - Limit(1). - Find(&prev).Error - - if errors.Is(err, gorm.ErrRecordNotFound) || prev.Id == 0 { - return nil, nil - } - if err != nil { - return nil, err - } - return &prev, nil -} - -func (s *recordingService) getTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) { - var population entity.ProjectFlockPopulation - err := tx. - Where("project_flock_kandang_id = ?", projectFlockKandangId). - Order("created_at DESC"). - First(&population).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, nil - } - if err != nil { - return 0, err - } - return int64(math.Round(population.InitialQuantity)), nil -} - -func (s *recordingService) getAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) { - var result struct { - TotalWeight float64 - TotalQty float64 - } - if err := tx.Model(&entity.RecordingBW{}). - Select("COALESCE(SUM(weight * qty), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty"). - Where("recording_id = ?", recordingID). - Scan(&result).Error; err != nil { - return 0, err - } - if result.TotalQty == 0 { - return 0, nil - } - return result.TotalWeight / result.TotalQty, nil -} - -func (s *recordingService) getFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { - var rows []struct { - UsageAmount float64 - UomName string - } - - if err := tx. - Table("recording_stocks"). - Select("COALESCE(recording_stocks.usage_amount, 0) AS usage_amount, LOWER(uoms.name) AS uom_name"). - Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id"). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN uoms ON uoms.id = products.uom_id"). - Where("recording_stocks.recording_id = ?", recordingID). - Scan(&rows).Error; err != nil { - return 0, err - } - - var total float64 - for _, row := range rows { - if row.UsageAmount <= 0 { - continue - } - switch strings.TrimSpace(row.UomName) { - case "kilogram", "kg", "kilograms", "kilo": - total += row.UsageAmount * 1000 - case "gram", "g", "grams": - total += row.UsageAmount - default: - total += row.UsageAmount - } - } - return total, nil -} - -func (s *recordingService) getFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) { - var result struct { - FcrID uint - } - if err := tx.Table("project_flock_kandangs"). - Select("project_flocks.fcr_id AS fcr_id"). - Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). - Where("project_flock_kandangs.id = ?", projectFlockKandangId). - Scan(&result).Error; err != nil { - return 0, err - } - return result.FcrID, nil -} - -func (s *recordingService) getFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) { - if fcrId == 0 { - return 0, false, nil - } - - var standard entity.FcrStandard - err := tx. - Where("fcr_id = ? AND weight >= ?", fcrId, currentWeightKg). - Order("weight ASC"). - First(&standard).Error - - if errors.Is(err, gorm.ErrRecordNotFound) { - err = tx. - Where("fcr_id = ?", fcrId). - Order("weight DESC"). - First(&standard).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, false, nil - } - } - if err != nil { - return 0, false, err - } - - weight := standard.Weight - if weight > 10 { - // assume already in grams - return weight / 1000, true, nil - } - return weight, true, nil -} - // === Unit Helpers === func toGrams(weight float64) float64 {