From 346ae153145f23945d9262d7a6561309a6142c26 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 22 Oct 2025 22:20:08 +0700 Subject: [PATCH 1/8] feat(BE): recording --- .../20251022143726_recording-tables.down.sql | 24 + .../20251022143726_recording-tables.up.sql | 150 ++++ internal/entities/recording.go | 32 +- internal/entities/recording_bw.go | 16 + internal/entities/recording_depletion.go | 13 + internal/entities/recording_stock.go | 14 + .../controllers/recording.controller.go | 10 +- .../recordings/dto/recording.dto.go | 50 +- .../recordings/services/recording.service.go | 645 +++++++++++++++++- .../validations/recording.validation.go | 43 +- 10 files changed, 951 insertions(+), 46 deletions(-) create mode 100644 internal/database/migrations/20251022143726_recording-tables.down.sql create mode 100644 internal/database/migrations/20251022143726_recording-tables.up.sql create mode 100644 internal/entities/recording_bw.go create mode 100644 internal/entities/recording_depletion.go create mode 100644 internal/entities/recording_stock.go 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"` } From 69ded31eb17f3e3faf62a038ba6cf89f6d61c6da Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 23 Oct 2025 15:23:28 +0700 Subject: [PATCH 2/8] feat(BE): recording --- internal/entities/recording.go | 3 +- .../projectflock_kandang.repository.go | 1 - .../controllers/recording.controller.go | 6 +- .../recordings/dto/recording.dto.go | 81 +++++++++- .../modules/production/recordings/module.go | 7 +- .../recordings/services/recording.service.go | 143 ++++++++++++------ .../validations/recording.validation.go | 12 +- 7 files changed, 186 insertions(+), 67 deletions(-) diff --git a/internal/entities/recording.go b/internal/entities/recording.go index f0923439..a3142e1d 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -11,8 +11,7 @@ type Recording struct { 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"` + 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"` diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index a5ceaf7f..fa6864fa 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -67,7 +67,6 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint Preload("ProjectFlock"). Preload("ProjectFlock.Flock"). Preload("Kandang"). - Preload("CreatedUser"). First(record, id).Error; err != nil { return nil, err } diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index ac5d7ffe..47f82068 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -71,7 +71,7 @@ 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), }) } @@ -92,7 +92,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), }) } @@ -119,7 +119,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 f6b6b1bd..4a6b4818 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -14,7 +14,6 @@ type RecordingBaseDTO struct { 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"` @@ -37,18 +36,51 @@ type RecordingListDTO struct { 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, ProjectFlockKandangId: e.ProjectFlockKandangId, RecordDatetime: e.RecordDatetime, - RecordDate: e.RecordDate, - Status: e.Status, - Ontime: e.Ontime, + RecordDate: recordDate, + Ontime: e.Ontime == 1, Day: e.Day, TotalDepletion: e.TotalDepletion, CumDepletionRate: e.CumDepletionRate, @@ -88,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/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 2a5fdecb..6deea620 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -9,6 +9,8 @@ import ( "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" @@ -28,16 +30,25 @@ type RecordingService interface { } 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, } } @@ -98,9 +109,16 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } - 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.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.ensureProductWarehousesExist(c, req.Stocks, req.Depletions); err != nil { + return nil, err } tx := s.Repository.DB().WithContext(c.Context()).Begin() @@ -122,20 +140,22 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } - status := 0 - if req.Status != nil { - status = *req.Status - } - ontime := false - if req.Ontime != nil { - ontime = *req.Ontime - } + 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, - Status: status, - Ontime: ontime, + RecordDate: &recordDate, + Ontime: boolToInt(ontimeFlag), Day: &nextDay, CreatedBy: 1, // TODO: replace with authenticated user } @@ -203,33 +223,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin 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 - } + 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 { @@ -239,6 +239,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } 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) @@ -246,6 +250,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } 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) @@ -280,6 +288,38 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { // === 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{}). @@ -319,6 +359,17 @@ func nextRecordingDay(days []int) int { 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 diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index ae409586..d143de4b 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -24,21 +24,15 @@ type ( type Create struct { 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 { - 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"` + 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 { From 79b3dd47b8ca8f89ad061321fe6913e3206c01d9 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 23 Oct 2025 22:00:55 +0700 Subject: [PATCH 3/8] fix[BE]: validate query page/limit defaults and add exists helpers --- .../repository/common.exists.repository.go | 19 +++++++++++++++++++ .../areas/controllers/area.controller.go | 4 ++++ .../banks/controllers/bank.controller.go | 4 ++++ .../banks/repositories/bank.repository.go | 5 +++++ .../master/banks/services/bank.service.go | 7 +++++++ .../controllers/customer.controller.go | 4 ++++ .../master/fcrs/controllers/fcr.controller.go | 4 ++++ .../flocks/controllers/flock.controller.go | 4 ++++ .../controllers/kandang.controller.go | 4 ++++ .../controllers/location.controller.go | 4 ++++ .../controllers/nonstock.controller.go | 4 ++++ .../product-category.controller.go | 4 ++++ .../controllers/product.controller.go | 4 ++++ .../controllers/supplier.controller.go | 4 ++++ .../repositories/supplier.repository.go | 6 +++++- .../suppliers/services/supplier.service.go | 13 +++++++++++++ .../master/uoms/controllers/uom.controller.go | 4 ++++ .../controllers/warehouse.controller.go | 4 ++++ tools/templates/controller.tmpl | 4 ++++ 19 files changed, 105 insertions(+), 1 deletion(-) diff --git a/internal/common/repository/common.exists.repository.go b/internal/common/repository/common.exists.repository.go index ef371330..c6bc11f0 100644 --- a/internal/common/repository/common.exists.repository.go +++ b/internal/common/repository/common.exists.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "gorm.io/gorm" ) @@ -32,3 +33,21 @@ func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeI } return count > 0, nil } + +func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) { + if field == "" { + return false, fmt.Errorf("field is required") + } + var count int64 + q := db.WithContext(ctx). + Model(new(T)). + Where(fmt.Sprintf("%s = ?", field), value). + Where("deleted_at IS NULL") + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/internal/modules/master/areas/controllers/area.controller.go b/internal/modules/master/areas/controllers/area.controller.go index e08dba7d..252bc769 100644 --- a/internal/modules/master/areas/controllers/area.controller.go +++ b/internal/modules/master/areas/controllers/area.controller.go @@ -29,6 +29,10 @@ func (u *AreaController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.AreaService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/banks/controllers/bank.controller.go b/internal/modules/master/banks/controllers/bank.controller.go index 7625d078..ffe61cea 100644 --- a/internal/modules/master/banks/controllers/bank.controller.go +++ b/internal/modules/master/banks/controllers/bank.controller.go @@ -29,6 +29,10 @@ func (u *BankController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.BankService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/banks/repositories/bank.repository.go b/internal/modules/master/banks/repositories/bank.repository.go index 53d27713..d309d3c1 100644 --- a/internal/modules/master/banks/repositories/bank.repository.go +++ b/internal/modules/master/banks/repositories/bank.repository.go @@ -11,6 +11,7 @@ import ( type BankRepository interface { repository.BaseRepository[entity.Bank] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + AccountNumberExists(ctx context.Context, accountNumber string, excludeID *uint) (bool, error) } type BankRepositoryImpl struct { @@ -28,3 +29,7 @@ func NewBankRepository(db *gorm.DB) BankRepository { func (r *BankRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Bank](ctx, r.db, name, excludeID) } + +func (r *BankRepositoryImpl) AccountNumberExists(ctx context.Context, accountNumber string, excludeID *uint) (bool, error) { + return repository.ExistsByField[entity.Bank](ctx, r.db, "account_number", accountNumber, excludeID) +} diff --git a/internal/modules/master/banks/services/bank.service.go b/internal/modules/master/banks/services/bank.service.go index b62bf864..83d3029d 100644 --- a/internal/modules/master/banks/services/bank.service.go +++ b/internal/modules/master/banks/services/bank.service.go @@ -87,6 +87,13 @@ func (s *bankService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.B return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with name %s already exists", req.Name)) } + if exists, err := s.Repository.AccountNumberExists(c.Context(), req.AccountNumber, nil); err != nil { + s.Log.Errorf("Failed to check bank account number: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check bank account number") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with account number %s already exists", req.AccountNumber)) + } + createBody := &entity.Bank{ Name: req.Name, Alias: req.Alias, diff --git a/internal/modules/master/customers/controllers/customer.controller.go b/internal/modules/master/customers/controllers/customer.controller.go index 2f9c0ed4..02805f6f 100644 --- a/internal/modules/master/customers/controllers/customer.controller.go +++ b/internal/modules/master/customers/controllers/customer.controller.go @@ -29,6 +29,10 @@ func (u *CustomerController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.CustomerService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/fcrs/controllers/fcr.controller.go b/internal/modules/master/fcrs/controllers/fcr.controller.go index 33353ffa..52db463d 100644 --- a/internal/modules/master/fcrs/controllers/fcr.controller.go +++ b/internal/modules/master/fcrs/controllers/fcr.controller.go @@ -29,6 +29,10 @@ func (u *FcrController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.FcrService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/flocks/controllers/flock.controller.go b/internal/modules/master/flocks/controllers/flock.controller.go index 8265f3e4..f8df0587 100644 --- a/internal/modules/master/flocks/controllers/flock.controller.go +++ b/internal/modules/master/flocks/controllers/flock.controller.go @@ -29,6 +29,10 @@ func (u *FlockController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.FlockService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/kandangs/controllers/kandang.controller.go b/internal/modules/master/kandangs/controllers/kandang.controller.go index 23d22334..b1d016df 100644 --- a/internal/modules/master/kandangs/controllers/kandang.controller.go +++ b/internal/modules/master/kandangs/controllers/kandang.controller.go @@ -31,6 +31,10 @@ func (u *KandangController) GetAll(c *fiber.Ctx) error { PicId: c.QueryInt("pic_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.KandangService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/locations/controllers/location.controller.go b/internal/modules/master/locations/controllers/location.controller.go index 8f8211d7..f360a9c9 100644 --- a/internal/modules/master/locations/controllers/location.controller.go +++ b/internal/modules/master/locations/controllers/location.controller.go @@ -30,6 +30,10 @@ func (u *LocationController) GetAll(c *fiber.Ctx) error { AreaId: c.QueryInt("area_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.LocationService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/nonstocks/controllers/nonstock.controller.go b/internal/modules/master/nonstocks/controllers/nonstock.controller.go index d8b688b7..d991c4da 100644 --- a/internal/modules/master/nonstocks/controllers/nonstock.controller.go +++ b/internal/modules/master/nonstocks/controllers/nonstock.controller.go @@ -29,6 +29,10 @@ func (u *NonstockController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.NonstockService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/product-categories/controllers/product-category.controller.go b/internal/modules/master/product-categories/controllers/product-category.controller.go index 778a3188..e4531a1f 100644 --- a/internal/modules/master/product-categories/controllers/product-category.controller.go +++ b/internal/modules/master/product-categories/controllers/product-category.controller.go @@ -29,6 +29,10 @@ func (u *ProductCategoryController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.ProductCategoryService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/products/controllers/product.controller.go b/internal/modules/master/products/controllers/product.controller.go index ee2c95f8..197a6b5f 100644 --- a/internal/modules/master/products/controllers/product.controller.go +++ b/internal/modules/master/products/controllers/product.controller.go @@ -30,6 +30,10 @@ func (u *ProductController) GetAll(c *fiber.Ctx) error { ProductCategoryID: c.QueryInt("product_category_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.ProductService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/suppliers/controllers/supplier.controller.go b/internal/modules/master/suppliers/controllers/supplier.controller.go index a76904a9..5d70e43e 100644 --- a/internal/modules/master/suppliers/controllers/supplier.controller.go +++ b/internal/modules/master/suppliers/controllers/supplier.controller.go @@ -29,6 +29,10 @@ func (u *SupplierController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.SupplierService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index 46fb2983..6b5a0ae2 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -11,7 +11,7 @@ import ( type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) - + AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) } type SupplierRepositoryImpl struct { @@ -29,3 +29,7 @@ func NewSupplierRepository(db *gorm.DB) SupplierRepository { func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Supplier](ctx, r.db, name, excludeID) } + +func (r *SupplierRepositoryImpl) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) { + return repository.ExistsByField[entity.Supplier](ctx, r.db, "alias", alias, excludeID) +} diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index f8422350..99e15b29 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -88,6 +88,13 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with name %s already exists", req.Name)) } + if exists, err := s.Repository.AliasExists(c.Context(), strings.TrimSpace(strings.ToUpper(req.Alias)), nil); err != nil { + s.Log.Errorf("Failed to check supplier alias: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier alias") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with alias %s already exists", strings.TrimSpace(strings.ToUpper(req.Alias)))) + } + typ := strings.ToUpper(req.Type) if !utils.IsValidCustomerSupplierType(typ) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid supplier type") @@ -143,6 +150,12 @@ func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint } if req.Alias != nil { + if exists, err := s.Repository.AliasExists(c.Context(), strings.TrimSpace(strings.ToUpper(*req.Alias)), &id); err != nil { + s.Log.Errorf("Failed to check supplier alias: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier alias") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with alias %s already exists", strings.TrimSpace(strings.ToUpper(*req.Alias)))) + } updateBody["alias"] = strings.TrimSpace(strings.ToUpper(*req.Alias)) } diff --git a/internal/modules/master/uoms/controllers/uom.controller.go b/internal/modules/master/uoms/controllers/uom.controller.go index 0bd3a382..ecef1f69 100644 --- a/internal/modules/master/uoms/controllers/uom.controller.go +++ b/internal/modules/master/uoms/controllers/uom.controller.go @@ -29,6 +29,10 @@ func (u *UomController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.UomService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go index b841d4ef..afa90660 100644 --- a/internal/modules/master/warehouses/controllers/warehouse.controller.go +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -30,6 +30,10 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error { AreaId: c.QueryInt("area_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.WarehouseService.GetAll(c, query) if err != nil { return err diff --git a/tools/templates/controller.tmpl b/tools/templates/controller.tmpl index 9fcf6d9b..f2eb615e 100644 --- a/tools/templates/controller.tmpl +++ b/tools/templates/controller.tmpl @@ -29,6 +29,10 @@ func (u *{{Pascal .Entity}}Controller) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.{{Pascal .Entity}}Service.GetAll(c, query) if err != nil { return err From aeeb5a38c137a1044898f7409a310d9e4e72165b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 24 Oct 2025 09:51:50 +0700 Subject: [PATCH 4/8] Feat[BE] : add avaibility DOC on lookup porject flock API, add note request json on chickin --- .../product_warehouse.controller.go | 6 +- .../services/product_warehouse.service.go | 20 +++ .../chickins/services/chickin.service.go | 6 +- .../validations/chickin.validation.go | 1 + .../controllers/projectflock.controller.go | 30 ++++- .../dto/projectflock_kandang.dto.go | 41 +++--- .../production/project_flocks/module.go | 6 +- .../services/projectflock.service.go | 123 +++++++++--------- 8 files changed, 137 insertions(+), 96 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index a0b72a4d..b44eab28 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -30,6 +30,10 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { WarehouseId: uint(c.QueryInt("warehouse_id", 0)), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.ProductWarehouseService.GetAll(c, query) if err != nil { return err @@ -71,5 +75,3 @@ func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error { Data: dto.ToProductWarehouseListDTO(*result), }) } - - diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 4fad5dc5..e9e31ab5 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -49,6 +49,26 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) return nil, 0, err } + if params.ProductId > 0 { + isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId) + if err != nil { + return nil, 0, err + } + if !isProductExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + } + + if params.WarehouseId > 0 { + isWarehouseExist, err := s.Repository.IsWarehouseExist(c.Context(), params.WarehouseId) + if err != nil { + return nil, 0, err + } + if !isWarehouseExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + } + offset := (params.Page - 1) * params.Limit productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 0df1b6b5..66793c8c 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -136,8 +136,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit if len(productWarehouses) == 0 { return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") } - - // Jumlahkan semua quantity DOC totalQuantity := 0.0 for _, pw := range productWarehouses { totalQuantity += pw.Quantity @@ -147,7 +145,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient quantity in Product Warehouses") } - // Buat satu chickin dengan total quantity chickinDate, err := utils.ParseDateString(req.ChickInDate) if err != nil { s.Log.Errorf("Failed to parse chickin date: %+v", err) @@ -157,7 +154,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit ProjectFlockKandangId: projectflockkandang.Id, ChickInDate: chickinDate, Quantity: totalQuantity, - Note: "", + Note: req.Note, CreatedBy: 1, //todo: ganti dengan user login } err = s.Repository.CreateOne(c.Context(), newChickin, nil) @@ -176,7 +173,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - // add ke detail chickin newChickinDetail := &entity.ProjectChickinDetail{ ProjectChickinId: newChickin.Id, ProductWarehouseId: pw.Id, diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go index c122c100..66d4924c 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -3,6 +3,7 @@ package validation type Create struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` + Note string `json:"note" validate:"omitempty` } type Update struct { diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index ca60d5df..668743b3 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -246,17 +246,39 @@ func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { } func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { - projectFlockIdStr := c.Query("project_flock_id", "") - kandangIdStr := c.Query("kandang_id", "") + projectFlockId := c.QueryInt("project_flock_id", 0) + kandangId := c.QueryInt("kandang_id", 0) - result, err := u.ProjectflockService.GetProjectFlockKandangByParams(c, "", projectFlockIdStr, kandangIdStr) + if projectFlockId == 0 || kandangId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id") + } + + result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId)) if err != nil { return err } + dtoResult := dto.ToProjectFlockKandangDTO(*result) + dtoResult.AvailableQuantity = float64(availableStock) + + // populate available quantity for each kandang inside project_flock + if dtoResult.ProjectFlock != nil { + for i := range dtoResult.ProjectFlock.Kandangs { + kand := &dtoResult.ProjectFlock.Kandangs[i] + if kand.Id == 0 { + continue + } + if q, qerr := u.ProjectflockService.GetAvailableDocQuantity(c, kand.Id); qerr == nil { + kand.AvailableQuantity = q + } + } + // remove inner kandangs from project_flock to avoid duplication + dtoResult.ProjectFlock.Kandangs = nil + } + return c.Status(fiber.StatusOK). JSON(response.Success{Code: fiber.StatusOK, Status: "success", Message: "Get projectflock kandang successfully", - Data: dto.ToProjectFlockKandangDTO(*result)}) + Data: dtoResult}) } diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index ff82fba9..27a68011 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -10,10 +10,9 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// internal DTO used only for lookup response: project flock with kandangs carrying pivot ids type KandangWithPivotDTO struct { kandangDTO.KandangBaseDTO - ProjectFlockKandangId *uint `json:"project_flock_kandang_id,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` } type ProjectFlockWithPivotDTO struct { @@ -28,11 +27,13 @@ type ProjectFlockWithPivotDTO struct { } type ProjectFlockKandangDTO struct { - Id uint `json:"id"` - ProjectFlockId uint `json:"project_flock_id"` - KandangId uint `json:"kandang_id"` - Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` - ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` + ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { @@ -44,7 +45,7 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD var pf *ProjectFlockWithPivotDTO if e.ProjectFlock.Id != 0 { - // build project flock with kandangs that include pivot ids + pfLocal := ProjectFlockWithPivotDTO{ ProjectFlockBaseDTO: ProjectFlockBaseDTO{ Id: e.ProjectFlock.Id, @@ -53,7 +54,6 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD Category: e.ProjectFlock.Category, } - // fill related small summaries if e.ProjectFlock.Flock.Id != 0 { mapped := ToFlockSummaryDTO(e.ProjectFlock.Flock) pfLocal.Flock = &mapped @@ -75,23 +75,16 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD pfLocal.CreatedUser = &mapped } - // build pivot map pivotMap := make(map[uint]uint) for _, ph := range e.ProjectFlock.KandangHistory { pivotMap[ph.KandangId] = ph.Id } - // populate kandangs with pivot ids for _, k := range e.ProjectFlock.Kandangs { kb := kandangDTO.ToKandangBaseDTO(k) - var pid *uint - if v, ok := pivotMap[k.Id]; ok { - vv := v - pid = &vv - } pfLocal.Kandangs = append(pfLocal.Kandangs, KandangWithPivotDTO{ - KandangBaseDTO: kb, - ProjectFlockKandangId: pid, + KandangBaseDTO: kb, + AvailableQuantity: 0, }) } @@ -99,10 +92,12 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD } return ProjectFlockKandangDTO{ - Id: e.Id, - ProjectFlockId: e.ProjectFlockId, - KandangId: e.KandangId, - Kandang: kandang, - ProjectFlock: pf, + Id: e.Id, + ProjectFlockKandangId: e.Id, + ProjectFlockId: e.ProjectFlockId, + KandangId: e.KandangId, + Kandang: kandang, + ProjectFlock: pf, + AvailableQuantity: 0, } } diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 994eb4a4..4fd932a4 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -9,8 +9,10 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" "gorm.io/gorm" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" @@ -27,6 +29,8 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid kandangRepo := rKandang.NewKandangRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) @@ -35,7 +39,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) } - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, approvalService, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index f9c7881e..23097585 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -4,14 +4,15 @@ import ( "context" "errors" "fmt" - "strconv" "strings" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + productWarehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + warehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -29,20 +30,23 @@ type ProjectflockService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) DeleteOne(ctx *fiber.Ctx, id uint) error - GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) + GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, int, error) + GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) } type projectflockService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ProjectflockRepository - FlockRepo flockRepository.FlockRepository - KandangRepo kandangRepository.KandangRepository - PivotRepo repository.ProjectFlockKandangRepository - ApprovalSvc commonSvc.ApprovalService - approvalWorkflow approvalutils.ApprovalWorkflowKey + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectflockRepository + FlockRepo flockRepository.FlockRepository + KandangRepo kandangRepository.KandangRepository + WarehouseRepo warehouseRepository.WarehouseRepository + ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository + ProjectFlockKandangRepo repository.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey } type FlockPeriodSummary struct { @@ -54,19 +58,23 @@ func NewProjectflockService( repo repository.ProjectflockRepository, flockRepo flockRepository.FlockRepository, kandangRepo kandangRepository.KandangRepository, - pivotRepo repository.ProjectFlockKandangRepository, + ProjectFlockKandangRepo repository.ProjectFlockKandangRepository, + warehouseRepo warehouseRepository.WarehouseRepository, + productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) ProjectflockService { return &projectflockService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - FlockRepo: flockRepo, - KandangRepo: kandangRepo, - PivotRepo: pivotRepo, - ApprovalSvc: approvalSvc, - approvalWorkflow: utils.ApprovalWorkflowProjectFlock, + Log: utils.Log, + Validate: validate, + Repository: repo, + FlockRepo: flockRepo, + KandangRepo: kandangRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockKandangRepo: ProjectFlockKandangRepo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } } @@ -641,55 +649,48 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func (s projectflockService) GetProjectFlockKandang(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, error) { - // keep for backward compatibility; delegate to new consolidated method - return s.GetProjectFlockKandangByParams(ctx, fmt.Sprintf("%d", id), "", "") -} +func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, int, error) { -func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { + availableStock, err := s.GetAvailableDocQuantity(ctx, kandangID) + if err != nil { + return nil, 0, err + } - pfk, err := s.PivotRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID) + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + return nil, 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") } - return nil, err + return nil, 0, err } - return pfk, nil + + return projectFlockKandang, int(availableStock), nil } -func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) { - idStr = strings.TrimSpace(idStr) - projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) - kandangIdStr = strings.TrimSpace(kandangIdStr) +func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { - if idStr != "" { - id, err := strconv.Atoi(idStr) - if err != nil || id <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - pfk, err := s.PivotRepo.GetByID(ctx.Context(), uint(id)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") - } - return nil, err - } - return pfk, nil + wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID) + if err != nil { + return 0, err } - if projectFlockIdStr == "" || kandangIdStr == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "Missing lookup parameters") + var productWarehouses []entity.ProductWarehouse + err = s.ProductWarehouseRepo.DB(). + WithContext(ctx.Context()). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", wh.Id). + Order("created_at DESC"). + Find(&productWarehouses).Error + if err != nil { + return 0, err } - pfid, err := strconv.Atoi(projectFlockIdStr) - if err != nil || pfid <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + + total := 0.0 + for _, pw := range productWarehouses { + total += pw.Quantity } - kid, err := strconv.Atoi(kandangIdStr) - if err != nil || kid <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") - } - return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) + return total, nil } func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { @@ -784,7 +785,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") } - pivotRepo := s.pivotRepoWithTx(dbTransaction) + ProjectFlockKandangRepo := s.ProjectFlockKandangRepoWithTx(dbTransaction) records := make([]*entity.ProjectFlockKandang, len(kandangIDs)) for i, id := range kandangIDs { records[i] = &entity.ProjectFlockKandang{ @@ -792,7 +793,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * KandangId: id, } } - if err := pivotRepo.CreateMany(ctx, records); err != nil { + if err := ProjectFlockKandangRepo.CreateMany(ctx, records); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil @@ -814,15 +815,15 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") } - if err := s.pivotRepoWithTx(dbTransaction).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { + if err := s.ProjectFlockKandangRepoWithTx(dbTransaction).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil } -func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { - if s.PivotRepo == nil { +func (s projectflockService) ProjectFlockKandangRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { + if s.ProjectFlockKandangRepo == nil { return repository.NewProjectFlockKandangRepository(dbTransaction) } - return s.PivotRepo.WithTx(dbTransaction) + return s.ProjectFlockKandangRepo.WithTx(dbTransaction) } From 222d53aa37cd276eef2f2439a533fb39ecb76fe8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 24 Oct 2025 10:25:05 +0700 Subject: [PATCH 5/8] FIX[BE] : use repository instead of raw query on service on productflock service --- .../product_warehouse.repository.go | 39 +++++++++++++------ .../chickins/services/chickin.service.go | 11 +----- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index cc4adf64..f1f1fa57 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -16,6 +16,7 @@ type ProductWarehouseRepository interface { ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) ExistsByID(ctx context.Context, id uint) (bool, error) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) + GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) } type ProductWarehouseRepositoryImpl struct { @@ -30,6 +31,17 @@ func NewProductWarehouseRepository(db *gorm.DB) ProductWarehouseRepository { } } +func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) { + return repository.Exists[entity.Product](ctx, r.db, productId) +} +func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) { + return repository.Exists[entity.Warehouse](ctx, r.db, warehouseId) +} + +func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductWarehouse](ctx, r.db, id) +} + func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Context, productId, warehouseId uint, excludeID *uint) (bool, error) { var count int64 query := r.db.WithContext(ctx).Model(&entity.ProductWarehouse{}). @@ -43,17 +55,6 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Cont return count > 0, nil } -func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) { - return repository.Exists[entity.Product](ctx, r.db, productId) -} -func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) { - return repository.Exists[entity.Warehouse](ctx, r.db, warehouseId) -} - -func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) { - return repository.Exists[entity.ProductWarehouse](ctx, r.db, id) -} - func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) { var count int64 if err := r.db.WithContext(ctx). @@ -72,3 +73,19 @@ func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehous } return &productWarehouse, nil } + +func (r *ProductWarehouseRepositoryImpl) GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) { + var productWarehouses []entity.ProductWarehouse + err := r.db.WithContext(ctx). + Table("product_warehouses"). + Select("product_warehouses.*"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId). + Order("product_warehouses.created_at DESC"). + Find(&productWarehouses).Error + if err != nil { + return nil, err + } + return productWarehouses, nil +} diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 66793c8c..ec2b31aa 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -121,14 +121,8 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - var productWarehouses []entity.ProductWarehouse - err = s.ProductWarehouseRepo.DB(). - WithContext(c.Context()). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). - Order("created_at DESC"). - Find(&productWarehouses).Error + // move complex DB query into repository for cleaner service + productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(c.Context(), "DOC", warehouse.Id) if err != nil { s.Log.Errorf("Failed to get product warehouses: %+v", err) return nil, err @@ -289,7 +283,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return rollback(err) } - // helper: restore quantities from details; returns (restored bool, error) restoreFromDetails := func() (bool, error) { var details []entity.ProjectChickinDetail if err := tx.WithContext(c.Context()).Where("project_chickin_id = ?", chickin.Id).Find(&details).Error; err != nil { From 7f2175a8cfc1a4741068c9913f008dfa03a35edf Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 24 Oct 2025 11:16:12 +0700 Subject: [PATCH 6/8] Feat[Be-117]: Menambahkan note upda update chickin api --- .../modules/production/chickins/services/chickin.service.go | 3 +++ .../production/chickins/validations/chickin.validation.go | 1 + 2 files changed, 4 insertions(+) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index ec2b31aa..f422666f 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -222,6 +222,9 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if req.ChickInDate != "" { updateBody["chick_in_date"] = req.ChickInDate } + if req.Note != "" { + updateBody["note"] = req.Note + } if len(updateBody) == 0 { return s.GetOne(c, id) } diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go index 66d4924c..9747ee07 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -8,6 +8,7 @@ type Create struct { type Update struct { ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` + Note string `json:"note" validate:"omitempty"` } type Query struct { From 3a162972ba2dfbb85ff882dc6ca073a356a729e3 Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 24 Oct 2025 12:46:43 +0700 Subject: [PATCH 7/8] feat/BE/US-76/TASK-122,133,121,120 Recording add create delete edit --- .../controllers/recording.controller.go | 23 +++++++++++++++++++ .../modules/production/recordings/route.go | 1 + .../recordings/services/recording.service.go | 16 +++++++++++++ 3 files changed, 40 insertions(+) diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index 47f82068..a924eb18 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -75,6 +75,29 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error { }) } +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, + }, + }) +} + func (u *RecordingController) CreateOne(c *fiber.Ctx) error { req := new(validation.Create) 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 6deea620..46ba36cc 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -24,6 +24,7 @@ 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 @@ -104,6 +105,21 @@ 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 From ef99a4a3c1f79e1f99b642328242218c0afdea51 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 24 Oct 2025 13:29:37 +0700 Subject: [PATCH 8/8] FIX[BE] : fix productwarehouses flags faram become multiple param --- .../product_warehouse.controller.go | 1 + .../services/product_warehouse.service.go | 8 ++++ .../product_warehouse.validation.go | 9 +++-- internal/utils/strings.go | 38 ++++++++++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index b44eab28..26f23278 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -28,6 +28,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), ProductId: uint(c.QueryInt("product_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)), + Flags: c.Query("flags", ""), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index e9e31ab5..3a0468ca 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -71,6 +71,8 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) offset := (params.Page - 1) * params.Limit + cleanFlags := utils.ParseFlags(params.Flags) + productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) @@ -82,6 +84,12 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("warehouse_id = ?", params.WarehouseId) } + if len(cleanFlags) > 0 { + db = db.Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products"). + Where("flags.name IN ?", cleanFlags) + } + return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 02648300..3a3acb28 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -13,8 +13,9 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` - WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` + WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + Flags string `query:"flags" validate:"omitempty"` } diff --git a/internal/utils/strings.go b/internal/utils/strings.go index f6560191..a58ba1ac 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -1,6 +1,9 @@ package utils -import "strings" +import ( + "sort" + "strings" +) // NormalizeTrim returns the input string without leading/trailing whitespace. func NormalizeTrim(value string) string { @@ -11,3 +14,36 @@ func NormalizeTrim(value string) string { func NormalizeUpper(value string) string { return strings.ToUpper(NormalizeTrim(value)) } + +// NormalizeFlag trims whitespace, removes surrounding brackets/quotes and returns upper-case flag +func NormalizeFlag(value string) string { + v := NormalizeTrim(value) + v = strings.Trim(v, "[]\"'") + return strings.ToUpper(v) +} + +// ParseFlags parses a raw flags string like "[DOC, PAKAN]" or "DOC,PAKAN" +// and returns a deduplicated, sorted slice of normalized flags (upper-case, trimmed). +func ParseFlags(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + set := make(map[string]struct{}, len(parts)) + for _, p := range parts { + f := NormalizeFlag(p) + if f == "" { + continue + } + set[f] = struct{}{} + } + if len(set) == 0 { + return nil + } + res := make([]string, 0, len(set)) + for k := range set { + res = append(res, k) + } + sort.Strings(res) + return res +}