diff --git a/internal/database/migrations/20251022143726_recording-tables.down.sql b/internal/database/migrations/20251022143726_recording-tables.down.sql new file mode 100644 index 00000000..1e710e78 --- /dev/null +++ b/internal/database/migrations/20251022143726_recording-tables.down.sql @@ -0,0 +1,24 @@ +BEGIN; + +--? Child Indexes(optional, biar rapi tapi klo gada juga ilang pas di drop) +DROP INDEX IF EXISTS idx_recording_stocks_product; +DROP INDEX IF EXISTS idx_recording_stocks_recording; + + +DROP INDEX IF EXISTS idx_recording_depl_recording; + +DROP INDEX IF EXISTS idx_recording_bws_recording; + +--? Child Tables +DROP TABLE IF EXISTS recording_stocks; +DROP TABLE IF EXISTS recording_depletions; +DROP TABLE IF EXISTS recording_bws; + +--? Parent Indexes ON recordings +DROP INDEX IF EXISTS uq_recordings_flock_record_date; +DROP INDEX IF EXISTS idx_recordings_flock_datetime; + +--? Parent table +DROP TABLE IF EXISTS recordings; + +COMMIT; \ No newline at end of file diff --git a/internal/database/migrations/20251022143726_recording-tables.up.sql b/internal/database/migrations/20251022143726_recording-tables.up.sql new file mode 100644 index 00000000..b961b75d --- /dev/null +++ b/internal/database/migrations/20251022143726_recording-tables.up.sql @@ -0,0 +1,150 @@ +BEGIN; + +--? RECORDINGS (tabel induk recording harian) +CREATE TABLE IF NOT EXISTS recordings ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL, + record_datetime TIMESTAMPTZ NOT NULL, + record_date DATE, + status INT NOT NULL DEFAULT 0, --? 0=draft,1=submitted,2=approved,3=rejected + ontime INT NOT NULL DEFAULT 0, --? 1=ontime,0=late (pakai INT/BOOLEAN sesuai preferensi) + day INT, + total_depletion INT, + cum_depletion_rate NUMERIC(7,3), + daily_gain NUMERIC(7,3), + avg_daily_gain NUMERIC(7,3), + cum_intake INT, + fcr_value NUMERIC(7,3), + total_chick BIGINT, + daily_depletion_rate NUMERIC(7,3), + cum_depletion INT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT, + + CONSTRAINT fk_recordings_project_flock + FOREIGN KEY (project_flock_id) REFERENCES project_flock_kandangs(id), + + CONSTRAINT fk_recordings_created_by + FOREIGN KEY (created_by) REFERENCES users(id), + + + CONSTRAINT chk_recordings_status + CHECK (status IN (0,1,2,3)), + + CONSTRAINT chk_recordings_ontime + CHECK (ontime IN (0,1)), + + CONSTRAINT chk_recordings_day + CHECK (day IS NULL OR day >= 1), + + CONSTRAINT chk_recordings_nonnegatives + CHECK ( + (total_depletion IS NULL OR total_depletion >= 0) AND + (cum_depletion IS NULL OR cum_depletion >= 0) AND + (total_chick IS NULL OR total_chick >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (fcr_value IS NULL OR fcr_value > 0) AND + (daily_depletion_rate IS NULL OR daily_depletion_rate >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) + ) +); + +--? Set record_date otomatis berdasarkan record_datetime (pakai zona Asia/Jakarta) +CREATE OR REPLACE FUNCTION trg_set_record_date() RETURNS trigger AS $$ +BEGIN + NEW.record_date := (NEW.record_datetime AT TIME ZONE 'Asia/Jakarta')::date; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS recordings_set_record_date_trg ON recordings; +CREATE TRIGGER recordings_set_record_date_trg +BEFORE INSERT OR UPDATE OF record_datetime ON recordings +FOR EACH ROW EXECUTE FUNCTION trg_set_record_date(); + + +CREATE INDEX IF NOT EXISTS idx_recordings_flock_datetime + ON recordings (project_flock_id, record_datetime); + +--? Unique harian (1 recording per hari dan per flock) +CREATE UNIQUE INDEX IF NOT EXISTS uq_recordings_flock_record_date + ON recordings (project_flock_id, record_date) + WHERE deleted_at IS NULL; + + +--? RECORDING_BWS (BW per recording) +CREATE TABLE IF NOT EXISTS recording_bws ( + id BIGSERIAL PRIMARY KEY, + recording_id BIGINT NOT NULL, + weight NUMERIC(8,2) NOT NULL, --? bobot per ekor/kelompok + qty INT NOT NULL DEFAULT 1, --? jumlah ekor pada bobot ini + notes VARCHAR, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_recording_bws_recording + FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE, + + CONSTRAINT chk_recording_bws_nonneg + CHECK (weight >= 0 AND qty >= 1) +); + +CREATE INDEX IF NOT EXISTS idx_recording_bws_recording + ON recording_bws (recording_id); + +--? RECORDING_DEPLETIONS +CREATE TABLE IF NOT EXISTS recording_depletions ( + id BIGSERIAL PRIMARY KEY, + recording_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + total BIGINT NOT NULL, + notes VARCHAR, + + CONSTRAINT fk_recording_depl_recording + FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE, + + CONSTRAINT fk_recording_depl_prodwh + FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id), + + CONSTRAINT chk_recording_depl_total + CHECK (total >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_recording_depl_recording + ON recording_depletions (recording_id); + +--? RECORDING_STOCKS +CREATE TABLE IF NOT EXISTS recording_stocks ( + id BIGSERIAL PRIMARY KEY, + recording_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + increase NUMERIC(10,3), --? penambahan (boleh NULL) + decrease NUMERIC(10,3), --? pengurangan (boleh NULL) + usage_amount BIGINT, --? pemakaian (opsional, jika konsep dipisah dari decrease) + notes VARCHAR, + + CONSTRAINT fk_recording_stocks_recording + FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE, + + CONSTRAINT fk_recording_stocks_prodwh + FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id), + + CONSTRAINT chk_recording_stocks_nonneg + CHECK ( + (increase IS NULL OR increase >= 0) AND + (decrease IS NULL OR decrease >= 0) AND + (usage_amount IS NULL OR usage_amount >= 0) + ) +); + +CREATE INDEX IF NOT EXISTS idx_recording_stocks_recording + ON recording_stocks (recording_id); + +CREATE INDEX IF NOT EXISTS idx_recording_stocks_product + ON recording_stocks (product_warehouse_id); + +COMMIT; diff --git a/internal/entities/recording.go b/internal/entities/recording.go index a6cf61b0..f0923439 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -7,12 +7,30 @@ import ( ) type Recording struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Id uint `gorm:"primaryKey"` + ProjectFlockKandangId uint `gorm:"column:project_flock_id;not null;index"` + RecordDatetime time.Time `gorm:"column:record_datetime;not null"` + RecordDate *time.Time `gorm:"column:record_date"` + Status int `gorm:"column:status;not null;default:0"` + Ontime bool `gorm:"column:ontime;not null;default:false"` + Day *int `gorm:"column:day"` + TotalDepletion *int `gorm:"column:total_depletion"` + CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` + DailyGain *float64 `gorm:"column:daily_gain"` + AvgDailyGain *float64 `gorm:"column:avg_daily_gain"` + CumIntake *int64 `gorm:"column:cum_intake"` + FcrValue *float64 `gorm:"column:fcr_value"` + TotalChick *int64 `gorm:"column:total_chick"` + DailyDepletionRate *float64 `gorm:"column:daily_depletion_rate"` + CumDepletion *int `gorm:"column:cum_depletion"` + CreatedBy uint `gorm:"column:created_by"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + BodyWeights []RecordingBW `gorm:"foreignKey:RecordingId;references:Id"` + Depletions []RecordingDepletion `gorm:"foreignKey:RecordingId;references:Id"` + Stocks []RecordingStock `gorm:"foreignKey:RecordingId;references:Id"` } diff --git a/internal/entities/recording_bw.go b/internal/entities/recording_bw.go new file mode 100644 index 00000000..a385e86e --- /dev/null +++ b/internal/entities/recording_bw.go @@ -0,0 +1,16 @@ + +package entities + +import "time" + +type RecordingBW struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + Weight float64 `gorm:"column:weight;not null"` + Qty int `gorm:"column:qty;not null;default:1"` + Notes *string `gorm:"column:notes"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` +} diff --git a/internal/entities/recording_depletion.go b/internal/entities/recording_depletion.go new file mode 100644 index 00000000..39a63cc3 --- /dev/null +++ b/internal/entities/recording_depletion.go @@ -0,0 +1,13 @@ +package entities + +type RecordingDepletion struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + Total int64 `gorm:"column:total;not null"` + Notes *string `gorm:"column:notes"` + + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} + diff --git a/internal/entities/recording_stock.go b/internal/entities/recording_stock.go new file mode 100644 index 00000000..de19885a --- /dev/null +++ b/internal/entities/recording_stock.go @@ -0,0 +1,14 @@ +package entities + +type RecordingStock struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + Increase *float64 `gorm:"column:increase"` + Decrease *float64 `gorm:"column:decrease"` + UsageAmount *int64 `gorm:"column:usage_amount"` + Notes *string `gorm:"column:notes"` + + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index 1215e8fc..ac5d7ffe 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -23,10 +23,14 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin } func (u *RecordingController) GetAll(c *fiber.Ctx) error { + projectFlockID := c.QueryInt("project_flock_kandang_id", 0) + query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + } + if projectFlockID > 0 { + query.ProjectFlockKandangId = uint(projectFlockID) } result, totalResults, err := u.RecordingService.GetAll(c, query) diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 7dbdec98..f6b6b1bd 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -10,15 +10,29 @@ import ( // === DTO Structs === type RecordingBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + RecordDatetime time.Time `json:"record_datetime"` + RecordDate *time.Time `json:"record_date,omitempty"` + Status int `json:"status"` + Ontime bool `json:"ontime"` + Day *int `json:"day,omitempty"` + TotalDepletion *int `json:"total_depletion,omitempty"` + CumDepletionRate *float64 `json:"cum_depletion_rate,omitempty"` + DailyGain *float64 `json:"daily_gain,omitempty"` + AvgDailyGain *float64 `json:"avg_daily_gain,omitempty"` + CumIntake *int64 `json:"cum_intake,omitempty"` + FcrValue *float64 `json:"fcr_value,omitempty"` + TotalChick *int64 `json:"total_chick,omitempty"` + DailyDepletionRate *float64 `json:"daily_depletion_rate,omitempty"` + CumDepletion *int `json:"cum_depletion,omitempty"` } type RecordingListDTO struct { RecordingBaseDTO CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type RecordingDetailDTO struct { @@ -29,23 +43,37 @@ type RecordingDetailDTO struct { func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { return RecordingBaseDTO{ - Id: e.Id, - Name: e.Name, + Id: e.Id, + ProjectFlockKandangId: e.ProjectFlockKandangId, + RecordDatetime: e.RecordDatetime, + RecordDate: e.RecordDate, + Status: e.Status, + Ontime: e.Ontime, + Day: e.Day, + TotalDepletion: e.TotalDepletion, + CumDepletionRate: e.CumDepletionRate, + DailyGain: e.DailyGain, + AvgDailyGain: e.AvgDailyGain, + CumIntake: e.CumIntake, + FcrValue: e.FcrValue, + TotalChick: e.TotalChick, + DailyDepletionRate: e.DailyDepletionRate, + CumDepletion: e.CumDepletion, } } func ToRecordingListDTO(e entity.Recording) RecordingListDTO { var createdUser *userDTO.UserBaseDTO - if e.CreatedUser.Id != 0 { - mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) createdUser = &mapped } return RecordingListDTO{ RecordingBaseDTO: ToRecordingBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, } } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 84220bd2..2a5fdecb 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -2,6 +2,11 @@ package service import ( "errors" + "fmt" + "math" + "sort" + "strings" + "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" @@ -37,7 +42,13 @@ func NewRecordingService(repo repository.RecordingRepository, validate *validato } func (s recordingService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("CreatedUser") + return db. + Preload("CreatedUser"). + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock"). + Preload("BodyWeights"). + Preload("Depletions"). + Preload("Stocks") } func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) { @@ -45,14 +56,22 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } - offset := (params.Page - 1) * params.Limit + limit := params.Limit + if limit == 0 { + limit = 10 + } + page := params.Page + if page == 0 { + page = 1 + } + offset := (page - 1) * limit - recordings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + if params.ProjectFlockKandangId != 0 { + db = db.Where("project_flock_id = ?", params.ProjectFlockKandangId) } - return db.Order("created_at DESC").Order("updated_at DESC") + return db.Order("record_datetime DESC").Order("created_at DESC") }) if err != nil { @@ -79,16 +98,82 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } - createBody := &entity.Recording{ - Name: req.Name, + recordTime, err := time.Parse(time.RFC3339, req.RecordDatetime) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "record_datetime must be in RFC3339 format") } - if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + tx := s.Repository.DB().WithContext(c.Context()).Begin() + if tx.Error != nil { + s.Log.Errorf("Failed to start recording transaction: %+v", tx.Error) + return nil, tx.Error + } + defer func() { + if r := recover(); r != nil { + _ = tx.Rollback() + panic(r) + } + }() + + nextDay, err := s.generateNextDay(tx, req.ProjectFlockKandangId) + if err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to determine recording day: %+v", err) + return nil, err + } + + status := 0 + if req.Status != nil { + status = *req.Status + } + ontime := false + if req.Ontime != nil { + ontime = *req.Ontime + } + + recording := &entity.Recording{ + ProjectFlockKandangId: req.ProjectFlockKandangId, + RecordDatetime: recordTime, + Status: status, + Ontime: ontime, + Day: &nextDay, + CreatedBy: 1, // TODO: replace with authenticated user + } + + if err := tx.Create(recording).Error; err != nil { + _ = tx.Rollback() s.Log.Errorf("Failed to create recording: %+v", err) return nil, err } - return s.GetOne(c, createBody.Id) + if err := s.persistBodyWeights(tx, 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 { + _ = 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 { + _ = tx.Rollback() + s.Log.Errorf("Failed to persist depletions: %+v", err) + return nil, err + } + + if err := s.computeAndUpdateMetrics(tx, recording); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to compute recording metrics: %+v", err) + return nil, err + } + + if err := tx.Commit().Error; err != nil { + s.Log.Errorf("Failed to commit recording transaction: %+v", err) + return nil, err + } + + return s.GetOne(c, recording.Id) } func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) { @@ -96,21 +181,86 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return nil, err } - updateBody := make(map[string]any) - - if req.Name != nil { - updateBody["name"] = *req.Name + tx := s.Repository.DB().WithContext(c.Context()).Begin() + if tx.Error != nil { + s.Log.Errorf("Failed to start recording transaction: %+v", tx.Error) + return nil, tx.Error } + defer func() { + if r := recover(); r != nil { + _ = tx.Rollback() + panic(r) + } + }() - if len(updateBody) == 0 { - return s.GetOne(c, id) - } - - if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + var recording entity.Recording + if err := tx.First(&recording, id).Error; err != nil { + _ = tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") } - s.Log.Errorf("Failed to update recording: %+v", err) + s.Log.Errorf("Failed to find recording: %+v", err) + return nil, err + } + + updateBody := make(map[string]any) + + if req.RecordDatetime != nil { + parsed, err := time.Parse(time.RFC3339, *req.RecordDatetime) + if err != nil { + _ = tx.Rollback() + return nil, fiber.NewError(fiber.StatusBadRequest, "record_datetime must be in RFC3339 format") + } + updateBody["record_datetime"] = parsed + recording.RecordDatetime = parsed + } + if req.Status != nil { + updateBody["status"] = *req.Status + recording.Status = *req.Status + } + if req.Ontime != nil { + updateBody["ontime"] = *req.Ontime + recording.Ontime = *req.Ontime + } + + if len(updateBody) > 0 { + if err := tx.Model(&entity.Recording{}).Where("id = ?", id).Updates(updateBody).Error; err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to update recording: %+v", err) + return nil, err + } + } + + if req.BodyWeights != nil { + if err := s.replaceBodyWeights(tx, recording.Id, req.BodyWeights); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to update body weights: %+v", err) + return nil, err + } + } + if req.Stocks != nil { + if err := s.replaceStocks(tx, recording.Id, req.Stocks); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to update stocks: %+v", err) + return nil, err + } + } + if req.Depletions != nil { + if err := s.replaceDepletions(tx, recording.Id, req.Depletions); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to update depletions: %+v", err) + return nil, err + } + } + + if err := s.computeAndUpdateMetrics(tx, &recording); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to recompute recording metrics: %+v", err) + return nil, err + } + + if err := tx.Commit().Error; err != nil { + s.Log.Errorf("Failed to commit recording transaction: %+v", err) return nil, err } @@ -127,3 +277,458 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { } return nil } + +// === Persistence Helpers === + +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 (s *recordingService) persistBodyWeights(tx *gorm.DB, recordingID uint, payload []validation.BodyWeight) error { + if len(payload) == 0 { + return nil + } + + bodyWeights := make([]entity.RecordingBW, len(payload)) + for i, bw := range payload { + bodyWeights[i] = entity.RecordingBW{ + RecordingId: recordingID, + Weight: bw.Weight, + Qty: bw.Qty, + Notes: bw.Notes, + } + } + + return tx.Create(&bodyWeights).Error +} + +func (s *recordingService) persistStocks(tx *gorm.DB, recordingID uint, payload []validation.Stock) error { + if len(payload) == 0 { + return nil + } + + stocks := make([]entity.RecordingStock, len(payload)) + for i, stock := range payload { + stocks[i] = entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: stock.ProductWarehouseId, + Notes: stock.Notes, + } + if stock.Increase != nil { + val := *stock.Increase + stocks[i].Increase = &val + } + if stock.Decrease != nil { + val := *stock.Decrease + stocks[i].Decrease = &val + } + if stock.UsageAmount != nil { + val := *stock.UsageAmount + stocks[i].UsageAmount = &val + } + } + + return tx.Create(&stocks).Error +} + +func (s *recordingService) persistDepletions(tx *gorm.DB, recordingID uint, payload []validation.Depletion) error { + if len(payload) == 0 { + return nil + } + + depletions := make([]entity.RecordingDepletion, len(payload)) + for i, depl := range payload { + total := depl.Total + depletions[i] = entity.RecordingDepletion{ + RecordingId: recordingID, + ProductWarehouseId: depl.ProductWarehouseId, + Total: total, + Notes: depl.Notes, + } + } + + return tx.Create(&depletions).Error +} + +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) + if err != nil { + return fmt.Errorf("sumRecordingDepletions: %w", err) + } + + prevRecording, err := s.getPreviousRecording(tx, recording.ProjectFlockKandangId, day) + if err != nil { + return fmt.Errorf("getPreviousRecording: %w", err) + } + + var prevCumDepletion int64 + var prevCumIntake float64 + var prevAvgWeight float64 + if prevRecording != nil { + if prevRecording.CumDepletion != nil { + prevCumDepletion = int64(*prevRecording.CumDepletion) + } + if prevRecording.CumIntake != nil { + prevCumIntake = float64(*prevRecording.CumIntake) + } + prevAvgWeight, err = s.getAverageBodyWeight(tx, prevRecording.Id) + if err != nil { + return fmt.Errorf("getAverageBodyWeight(prev): %w", err) + } + } + + totalChick, err := s.getTotalChick(tx, recording.ProjectFlockKandangId) + if err != nil { + return fmt.Errorf("getTotalChick: %w", err) + } + + currentAvgWeight, err := s.getAverageBodyWeight(tx, recording.Id) + if err != nil { + return fmt.Errorf("getAverageBodyWeight(current): %w", err) + } + + usageInGrams, err := s.getFeedUsageInGrams(tx, recording.Id) + if err != nil { + return fmt.Errorf("getFeedUsageInGrams: %w", err) + } + + fcrId, err := s.getFcrID(tx, recording.ProjectFlockKandangId) + if err != nil { + return fmt.Errorf("getFcrID: %w", err) + } + + currentAvgGrams := toGrams(currentAvgWeight) + currentAvgKg := gramsToKg(currentAvgGrams) + prevAvgGrams := toGrams(prevAvgWeight) + + totalDepletionInt := int(totalDepletion) + cumDepletion := prevCumDepletion + totalDepletion + cumDepletionInt := int(cumDepletion) + + updates := map[string]any{ + "total_depletion": totalDepletionInt, + "cum_depletion": cumDepletionInt, + } + + recording.TotalDepletion = &totalDepletionInt + recording.CumDepletion = &cumDepletionInt + + if totalChick > 0 { + updates["total_chick"] = totalChick + recording.TotalChick = &totalChick + + cumRate := (float64(cumDepletion) / float64(totalChick)) * 100 + updates["cum_depletion_rate"] = cumRate + recording.CumDepletionRate = &cumRate + + remainingAfter := totalChick - cumDepletion + if remainingAfter <= 0 { + remainingAfter = 1 + } + dailyRate := (float64(totalDepletion) / float64(remainingAfter)) * 100 + updates["daily_depletion_rate"] = dailyRate + recording.DailyDepletionRate = &dailyRate + } else { + updates["total_chick"] = gorm.Expr("NULL") + updates["cum_depletion_rate"] = gorm.Expr("NULL") + updates["daily_depletion_rate"] = gorm.Expr("NULL") + recording.TotalChick = nil + recording.CumDepletionRate = nil + recording.DailyDepletionRate = nil + } + + if currentAvgGrams > 0 && prevAvgGrams > 0 { + dailyGainKg := (currentAvgGrams - prevAvgGrams) / 1000 + updates["daily_gain"] = dailyGainKg + recording.DailyGain = &dailyGainKg + } else { + updates["daily_gain"] = gorm.Expr("NULL") + recording.DailyGain = nil + } + + if fcrId != 0 && currentAvgKg > 0 && day > 0 { + if fcrWeightKg, ok, err := s.getFcrStandardWeightKg(tx, fcrId, currentAvgKg); err != nil { + return fmt.Errorf("getFcrStandardWeightKg: %w", err) + } else if ok { + avgDailyGain := (currentAvgKg - fcrWeightKg) / float64(day) + updates["avg_daily_gain"] = avgDailyGain + recording.AvgDailyGain = &avgDailyGain + } else { + updates["avg_daily_gain"] = gorm.Expr("NULL") + recording.AvgDailyGain = nil + } + } else { + updates["avg_daily_gain"] = gorm.Expr("NULL") + recording.AvgDailyGain = nil + } + + if usageInGrams > 0 && totalChick > 0 { + var cumIntakeValue float64 + if prevRecording == nil || prevRecording.CumIntake == nil { + cumIntakeValue = usageInGrams / float64(totalChick) + } else { + remaining := float64(totalChick - cumDepletion) + if remaining <= 0 { + remaining = float64(totalChick) + } + cumIntakeValue = prevCumIntake + (usageInGrams / remaining) + } + cumIntakeRounded := int64(math.Round(cumIntakeValue)) + updates["cum_intake"] = cumIntakeRounded + recording.CumIntake = &cumIntakeRounded + } else if prevRecording != nil && prevRecording.CumIntake != nil { + // Keep previous cumulative intake if no additional feed usage provided + updates["cum_intake"] = *prevRecording.CumIntake + recording.CumIntake = prevRecording.CumIntake + } else { + updates["cum_intake"] = gorm.Expr("NULL") + recording.CumIntake = nil + } + + if usageInGrams > 0 && currentAvgKg > 0 { + feedUsageKg := usageInGrams / 1000 + fcrValue := feedUsageKg / currentAvgKg + updates["fcr_value"] = fcrValue + recording.FcrValue = &fcrValue + } else { + updates["fcr_value"] = gorm.Expr("NULL") + recording.FcrValue = nil + } + + if err := tx.Model(&entity.Recording{}). + Where("id = ?", recording.Id). + Updates(updates).Error; err != nil { + return err + } + + 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 { + if weight <= 0 { + return 0 + } + if weight > 10 { + return weight + } + return weight * 1000 +} + +func gramsToKg(value float64) float64 { + if value <= 0 { + return 0 + } + return value / 1000 +} diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 95505746..ae409586 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -1,15 +1,48 @@ package validation +type ( + BodyWeight struct { + Weight float64 `json:"weight" validate:"required"` + Qty int `json:"qty" validate:"required,number,min=1"` + Notes *string `json:"notes,omitempty" validate:"omitempty"` + } + + Stock struct { + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Increase *float64 `json:"increase,omitempty" validate:"omitempty"` + Decrease *float64 `json:"decrease,omitempty" validate:"omitempty"` + UsageAmount *int64 `json:"usage_amount,omitempty" validate:"omitempty,min=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty"` + } + + Depletion struct { + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Total int64 `json:"total" validate:"required,number,min=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty"` + } +) + type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` + RecordDatetime string `json:"record_datetime" validate:"required,datetime=2006-01-02T15:04:05Z07:00"` + Status *int `json:"status,omitempty" validate:"omitempty,oneof=0 1 2 3"` + Ontime *bool `json:"ontime,omitempty" validate:"omitempty"` + BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"` + Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` + Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + RecordDatetime *string `json:"record_datetime,omitempty" validate:"omitempty,datetime=2006-01-02T15:04:05Z07:00"` + Status *int `json:"status,omitempty" validate:"omitempty,oneof=0 1 2 3"` + Ontime *bool `json:"ontime,omitempty" validate:"omitempty"` + BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"` + Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` + Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` }