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..a3142e1d 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -7,12 +7,29 @@ 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"` + Ontime int `gorm:"column:ontime;not null;default:0"` + 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..a924eb18 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) @@ -67,7 +71,30 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get recording successfully", - Data: dto.ToRecordingListDTO(*result), + Data: dto.ToRecordingDetailDTO(*result), + }) +} + +func (u *RecordingController) GetNextDay(c *fiber.Ctx) error { + projectFlockID := c.QueryInt("project_flock_kandang_id", 0) + if projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get next recording day successfully", + Data: fiber.Map{ + "project_flock_kandang_id": projectFlockID, + "next_day": nextDay, + }, }) } @@ -88,7 +115,7 @@ func (u *RecordingController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create recording successfully", - Data: dto.ToRecordingListDTO(*result), + Data: dto.ToRecordingDetailDTO(*result), }) } @@ -115,7 +142,7 @@ func (u *RecordingController) UpdateOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Update recording successfully", - Data: dto.ToRecordingListDTO(*result), + Data: dto.ToRecordingDetailDTO(*result), }) } diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 7dbdec98..4a6b4818 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -10,42 +10,102 @@ 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"` + 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 { RecordingListDTO + BodyWeights []RecordingBodyWeightDTO `json:"body_weights"` + Depletions []RecordingDepletionDTO `json:"depletions"` + Stocks []RecordingStockDTO `json:"stocks"` +} + +type RecordingBodyWeightDTO struct { + Weight float64 `json:"weight"` + Qty int `json:"qty"` + Notes *string `json:"notes,omitempty"` +} + +type RecordingDepletionDTO struct { + ProductWarehouseId uint `json:"product_warehouse_id"` + Total int64 `json:"total"` + Notes *string `json:"notes,omitempty"` +} + +type RecordingStockDTO struct { + ProductWarehouseId uint `json:"product_warehouse_id"` + Increase *float64 `json:"increase,omitempty"` + Decrease *float64 `json:"decrease,omitempty"` + UsageAmount *int64 `json:"usage_amount,omitempty"` + Notes *string `json:"notes,omitempty"` } // === Mapper Functions === func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { + recordDate := e.RecordDate + if recordDate == nil { + rd := time.Date( + e.RecordDatetime.Year(), + e.RecordDatetime.Month(), + e.RecordDatetime.Day(), + 0, 0, 0, 0, + e.RecordDatetime.Location(), + ) + recordDate = &rd + } return RecordingBaseDTO{ - Id: e.Id, - Name: e.Name, + Id: e.Id, + ProjectFlockKandangId: e.ProjectFlockKandangId, + RecordDatetime: e.RecordDatetime, + RecordDate: recordDate, + Ontime: e.Ontime == 1, + 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, } } @@ -60,5 +120,46 @@ func ToRecordingListDTOs(e []entity.Recording) []RecordingListDTO { func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { return RecordingDetailDTO{ RecordingListDTO: ToRecordingListDTO(e), + BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights), + Depletions: ToRecordingDepletionDTOs(e.Depletions), + Stocks: ToRecordingStockDTOs(e.Stocks), } } + +func ToRecordingBodyWeightDTOs(bodyWeights []entity.RecordingBW) []RecordingBodyWeightDTO { + result := make([]RecordingBodyWeightDTO, len(bodyWeights)) + for i, bw := range bodyWeights { + result[i] = RecordingBodyWeightDTO{ + Weight: bw.Weight, + Qty: bw.Qty, + Notes: bw.Notes, + } + } + return result +} + +func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []RecordingDepletionDTO { + result := make([]RecordingDepletionDTO, len(depletions)) + for i, d := range depletions { + result[i] = RecordingDepletionDTO{ + ProductWarehouseId: d.ProductWarehouseId, + Total: d.Total, + Notes: d.Notes, + } + } + return result +} + +func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO { + result := make([]RecordingStockDTO, len(stocks)) + for i, s := range stocks { + result[i] = RecordingStockDTO{ + ProductWarehouseId: s.ProductWarehouseId, + Increase: s.Increase, + Decrease: s.Decrease, + UsageAmount: s.UsageAmount, + Notes: s.Notes, + } + } + return result +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 36ae8dd7..91151a9c 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -5,6 +5,8 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" @@ -16,11 +18,12 @@ type RecordingModule struct{} func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { recordingRepo := rRecording.NewRecordingRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) - recordingService := sRecording.NewRecordingService(recordingRepo, validate) + recordingService := sRecording.NewRecordingService(recordingRepo, projectFlockKandangRepo, productWarehouseRepo, validate) userService := sUser.NewUserService(userRepo, validate) RecordingRoutes(router, userService, recordingService) } - diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go index 6852a1ba..3af2b9cf 100644 --- a/internal/modules/production/recordings/route.go +++ b/internal/modules/production/recordings/route.go @@ -21,6 +21,7 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) route.Get("/", ctrl.GetAll) + route.Get("/next-day", ctrl.GetNextDay) route.Post("/", ctrl.CreateOne) route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 84220bd2..46ba36cc 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -2,8 +2,15 @@ package service import ( "errors" + "fmt" + "math" + "sort" + "strings" + "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -17,27 +24,43 @@ import ( type RecordingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error) + GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint) (int, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) DeleteOne(ctx *fiber.Ctx, id uint) error } type recordingService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.RecordingRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.RecordingRepository + ProjectFlockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository } -func NewRecordingService(repo repository.RecordingRepository, validate *validator.Validate) RecordingService { +func NewRecordingService( + repo repository.RecordingRepository, + projectFlockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + validate *validator.Validate, +) RecordingService { return &recordingService{ - Log: utils.Log, - Validate: validate, - Repository: repo, + Log: utils.Log, + Validate: validate, + Repository: repo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ProductWarehouseRepo: productWarehouseRepo, } } 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 +68,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 { @@ -74,21 +105,111 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro return recording, nil } +func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) (int, error) { + if projectFlockKandangId == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + db := s.Repository.DB().WithContext(c.Context()) + next, err := s.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 + } + + return next, nil +} + func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - createBody := &entity.Recording{ - Name: req.Name, + if _, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found") + } + s.Log.Errorf("Failed to get project flock kandang: %+v", err) + return nil, err } - if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions); err != nil { + return nil, err + } + + 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 + } + + currentTime := time.Now().UTC() + recordTime := currentTime + recordDate := time.Date( + recordTime.Year(), + recordTime.Month(), + recordTime.Day(), + 0, 0, 0, 0, + recordTime.Location(), + ) + ontimeFlag := computeOntime(recordTime, currentTime) + + recording := &entity.Recording{ + ProjectFlockKandangId: req.ProjectFlockKandangId, + RecordDatetime: recordTime, + RecordDate: &recordDate, + Ontime: boolToInt(ontimeFlag), + 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 +217,74 @@ 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 + } + + ontimeValue := boolToInt(computeOntime(recording.RecordDatetime, time.Now().UTC())) + if err := tx.Model(&entity.Recording{}).Where("id = ?", id).Update("ontime", ontimeValue).Error; err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to refresh ontime flag: %+v", err) + return nil, err + } + recording.Ontime = ontimeValue + + 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.ensureProductWarehousesExist(c, req.Stocks, nil); err != nil { + _ = tx.Rollback() + return nil, err + } + 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.ensureProductWarehousesExist(c, nil, req.Depletions); err != nil { + _ = tx.Rollback() + return nil, err + } + 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 +301,501 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { } return nil } + +// === Persistence Helpers === + +func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion) error { + idSet := make(map[uint]struct{}) + + for _, stock := range stocks { + if stock.ProductWarehouseId != 0 { + idSet[stock.ProductWarehouseId] = struct{}{} + } + } + for _, dep := range depletions { + if dep.ProductWarehouseId != 0 { + idSet[dep.ProductWarehouseId] = struct{}{} + } + } + + if len(idSet) == 0 { + return nil + } + + for id := range idSet { + ok, err := s.ProductWarehouseRepo.ExistsByID(c.Context(), id) + if err != nil { + s.Log.Errorf("Failed to validate product warehouse %d: %+v", id, err) + return err + } + if !ok { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d not found", id)) + } + } + + 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) +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} + +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..d143de4b 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -1,15 +1,42 @@ 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"` + 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"` + 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"` }