feat/BE/US-76/TASK-122,133,121,120 Recording add create delete edit

This commit is contained in:
ragilap
2025-10-26 15:27:19 +07:00
parent cedd5365d8
commit 8ae614540f
13 changed files with 639 additions and 579 deletions
@@ -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
}
@@ -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)
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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 {