From 411d6fe6a91fe7dda64e32fbc1d60ef87f706362 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 29 Dec 2025 09:38:49 +0700 Subject: [PATCH 01/29] feat(BE-281): deleting bw in recording --- ...ng_egg_and_deleting_recording_bws.down.sql | 55 ++++++++++++++ ...ding_egg_and_deleting_recording_bws.up.sql | 46 ++++++++++++ internal/entities/recording.go | 1 - .../recordings/dto/recording.dto.go | 20 ----- .../repositories/recording.repository.go | 1 - .../recordings/services/recording.service.go | 73 +++---------------- .../validations/recording.validation.go | 8 -- internal/utils/recording/util.recording.go | 42 ----------- 8 files changed, 110 insertions(+), 136 deletions(-) create mode 100644 internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql create mode 100644 internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql diff --git a/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql new file mode 100644 index 00000000..a52551bc --- /dev/null +++ b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql @@ -0,0 +1,55 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS recording_bws ( + id BIGSERIAL PRIMARY KEY, + recording_id BIGINT NOT NULL, + avg_weight NUMERIC(8,2) NOT NULL, + qty NUMERIC(15,3) NOT NULL DEFAULT 1, + total_weight NUMERIC(10,3) NOT NULL, + 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 (avg_weight >= 0 AND qty >= 0 AND total_weight >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_recording_bws_recording + ON recording_bws (recording_id); + +ALTER TABLE recordings + DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v3; + +ALTER TABLE recordings + DROP COLUMN IF EXISTS hand_day, + DROP COLUMN IF EXISTS hand_house, + DROP COLUMN IF EXISTS feed_intake, + DROP COLUMN IF EXISTS egg_mesh, + DROP COLUMN IF EXISTS egg_weight; + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_nonnegatives_v2 CHECK ( + (total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (fcr_value IS NULL OR fcr_value >= 0) AND + (total_chick_qty IS NULL OR total_chick_qty >= 0) + ); + +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; + +ALTER TABLE recording_eggs + DROP COLUMN IF EXISTS fcr_value, + ALTER COLUMN weight TYPE NUMERIC(10,3) USING weight::NUMERIC(10,3); + +ALTER TABLE recording_eggs + ADD CONSTRAINT chk_recording_eggs_qty CHECK ( + qty >= 0 AND (weight IS NULL OR weight >= 0) + ); + +COMMIT; diff --git a/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql new file mode 100644 index 00000000..3617a71b --- /dev/null +++ b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql @@ -0,0 +1,46 @@ +BEGIN; + +ALTER TABLE recordings + DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v2; + +ALTER TABLE recordings + ADD COLUMN IF NOT EXISTS hand_day NUMERIC(15,3), + ADD COLUMN IF NOT EXISTS hand_house NUMERIC(15,3), + ADD COLUMN IF NOT EXISTS feed_intake NUMERIC(15,3), + ADD COLUMN IF NOT EXISTS egg_mesh NUMERIC(15,3), + ADD COLUMN IF NOT EXISTS egg_weight NUMERIC(15,3); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_nonnegatives_v3 CHECK ( + (total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (fcr_value IS NULL OR fcr_value >= 0) AND + (total_chick_qty IS NULL OR total_chick_qty >= 0) AND + (hand_day IS NULL OR hand_day >= 0) AND + (hand_house IS NULL OR hand_house >= 0) AND + (feed_intake IS NULL OR feed_intake >= 0) AND + (egg_mesh IS NULL OR egg_mesh >= 0) AND + (egg_weight IS NULL OR egg_weight >= 0) + ); + +ALTER TABLE recording_eggs + ADD COLUMN IF NOT EXISTS fcr_value NUMERIC(15,3), + ALTER COLUMN weight TYPE NUMERIC(15,3) USING weight::NUMERIC(15,3); + +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; + +ALTER TABLE recording_eggs + ADD CONSTRAINT chk_recording_eggs_qty CHECK ( + qty >= 0 AND + (weight IS NULL OR weight >= 0) AND + (fcr_value IS NULL OR fcr_value >= 0) + ); + +DROP INDEX IF EXISTS idx_recording_bws_recording; +DROP TABLE IF EXISTS recording_bws; + +COMMIT; diff --git a/internal/entities/recording.go b/internal/entities/recording.go index 42535365..7b4497e3 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -25,7 +25,6 @@ type Recording struct { 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"` Eggs []RecordingEgg `gorm:"foreignKey:RecordingId;references:Id"` diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 51fba8a4..53106d84 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -39,18 +39,11 @@ type RecordingListDTO struct { type RecordingDetailDTO struct { RecordingListDTO - BodyWeights []RecordingBodyWeightDTO `json:"body_weights"` Depletions []RecordingDepletionDTO `json:"depletions"` Stocks []RecordingStockDTO `json:"stocks"` Eggs []RecordingEggDTO `json:"eggs"` } -type RecordingBodyWeightDTO struct { - AvgWeight float64 `json:"avg_weight"` - Qty float64 `json:"qty"` - TotalWeight float64 `json:"total_weight"` -} - type RecordingDepletionDTO struct { ProductWarehouseId uint `json:"product_warehouse_id"` Qty float64 `json:"qty"` @@ -183,25 +176,12 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { return RecordingDetailDTO{ RecordingListDTO: listDTO, - BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights), Depletions: ToRecordingDepletionDTOs(e.Depletions), Stocks: ToRecordingStockDTOs(e.Stocks), Eggs: eggs, } } -func ToRecordingBodyWeightDTOs(bodyWeights []entity.RecordingBW) []RecordingBodyWeightDTO { - result := make([]RecordingBodyWeightDTO, len(bodyWeights)) - for i, bw := range bodyWeights { - result[i] = RecordingBodyWeightDTO{ - AvgWeight: bw.AvgWeight, - Qty: bw.Qty, - TotalWeight: bw.TotalWeight, - } - } - return result -} - func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []RecordingDepletionDTO { result := make([]RecordingDepletionDTO, len(depletions)) for i, d := range depletions { diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 6e362ba7..a273d88c 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -66,7 +66,6 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser"). Preload("ProjectFlockKandang"). Preload("ProjectFlockKandang.ProjectFlock"). - Preload("BodyWeights"). Preload("Depletions"). Preload("Depletions.ProductWarehouse"). Preload("Depletions.ProductWarehouse.Product"). diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index a83c1128..d7f2c3e0 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -233,12 +233,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - mappedBodyWeights := recordingutil.MapBodyWeights(createdRecording.Id, req.BodyWeights) - if err := s.Repository.CreateBodyWeights(tx, mappedBodyWeights); err != nil { - s.Log.Errorf("Failed to persist body weights: %+v", err) - return err - } - mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { s.Log.Errorf("Failed to persist stocks: %+v", err) @@ -291,7 +285,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return nil, err } - if req.BodyWeights == nil && req.Stocks == nil && req.Depletions == nil && req.Eggs == nil { + if req.Stocks == nil && req.Depletions == nil && req.Eggs == nil { return s.GetOne(c, id) } @@ -311,12 +305,11 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } recordingEntity = recording - hasBodyChanges := req.BodyWeights != nil hasStockChanges := req.Stocks != nil hasDepletionChanges := req.Depletions != nil hasEggChanges := req.Eggs != nil - if !hasBodyChanges && !hasStockChanges && !hasDepletionChanges && !hasEggChanges { + if !hasStockChanges && !hasDepletionChanges && !hasEggChanges { return nil } @@ -346,17 +339,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if hasBodyChanges { - if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil { - s.Log.Errorf("Failed to clear body weights: %+v", err) - return err - } - if err := s.Repository.CreateBodyWeights(tx, recordingutil.MapBodyWeights(recordingEntity.Id, req.BodyWeights)); err != nil { - s.Log.Errorf("Failed to update body weights: %+v", err) - return err - } - } - if hasStockChanges { existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id) if err != nil { @@ -432,7 +414,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if hasBodyChanges || hasStockChanges || hasDepletionChanges { + if hasStockChanges || hasDepletionChanges { if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { s.Log.Errorf("Failed to recompute recording metrics: %+v", err) return err @@ -775,7 +757,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var prevCumDepletionQty float64 var prevCumIntake float64 - var prevAvgWeight float64 if prevRecording != nil { if prevRecording.TotalDepletionQty != nil { prevCumDepletionQty = *prevRecording.TotalDepletionQty @@ -783,10 +764,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm if prevRecording.CumIntake != nil { prevCumIntake = float64(*prevRecording.CumIntake) } - prevAvgWeight, err = s.Repository.GetAverageBodyWeight(tx, prevRecording.Id) - if err != nil { - return fmt.Errorf("getAverageBodyWeight(prev): %w", err) - } } totalChick, err := s.Repository.GetTotalChick(tx, recording.ProjectFlockKandangId) @@ -794,21 +771,11 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return fmt.Errorf("getTotalChick: %w", err) } - currentAvgWeight, err := s.Repository.GetAverageBodyWeight(tx, recording.Id) - if err != nil { - return fmt.Errorf("getAverageBodyWeight(current): %w", err) - } - usageInGrams, err := s.Repository.GetFeedUsageInGrams(tx, recording.Id) if err != nil { return fmt.Errorf("getFeedUsageInGrams: %w", err) } - currentAvgGrams := recordingutil.ToGrams(currentAvgWeight) - currentAvgKg := recordingutil.GramsToKg(currentAvgGrams) - prevAvgGrams := recordingutil.ToGrams(prevAvgWeight) - prevAvgKg := recordingutil.GramsToKg(prevAvgGrams) - currentDepletion := float64(totalDepletionQty) cumDepletionQty := prevCumDepletionQty + currentDepletion @@ -840,25 +807,10 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.CumDepletionRate = nil } - if currentAvgGrams > 0 && prevAvgGrams > 0 { - dailyGainKg := (currentAvgGrams - prevAvgGrams) / 1000 - updates["daily_gain"] = dailyGainKg - recording.DailyGain = &dailyGainKg - } else { - dailyGainKg := 0.0 - updates["daily_gain"] = dailyGainKg - recording.DailyGain = &dailyGainKg - } - - if currentAvgKg > 0 && remainingChick > 0 { - avgDailyGain := (currentAvgKg - prevAvgKg) / remainingChick - updates["avg_daily_gain"] = avgDailyGain - recording.AvgDailyGain = &avgDailyGain - } else { - avgDailyGain := 0.0 - updates["avg_daily_gain"] = avgDailyGain - recording.AvgDailyGain = &avgDailyGain - } + updates["daily_gain"] = gorm.Expr("NULL") + updates["avg_daily_gain"] = gorm.Expr("NULL") + recording.DailyGain = nil + recording.AvgDailyGain = nil if usageInGrams > 0 && totalChick > 0 { var cumIntakeValue float64 @@ -882,15 +834,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm 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 - } + updates["fcr_value"] = gorm.Expr("NULL") + recording.FcrValue = nil if err := s.Repository.WithTx(tx).PatchOne(ctx, recording.Id, updates, nil); err != nil { return err diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 28c38ff5..a1d6aaf7 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -1,12 +1,6 @@ package validation type ( - BodyWeight struct { - AvgWeight float64 `json:"avg_weight" validate:"required"` - Qty float64 `json:"qty" validate:"required,gt=0"` - TotalWeight *float64 `json:"total_weight,omitempty" validate:"omitempty,gte=0"` - } - Stock struct { ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` Qty float64 `json:"qty" validate:"required,gte=0"` @@ -27,14 +21,12 @@ type ( type Create struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` - BodyWeights []BodyWeight `json:"body_weights" validate:"dive"` Stocks []Stock `json:"stocks" validate:"dive"` Depletions []Depletion `json:"depletions" validate:"dive"` Eggs []Egg `json:"eggs" validate:"omitempty,dive"` } type Update struct { - 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"` Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index f10926dc..91c9cc4b 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -5,31 +5,6 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" ) -func MapBodyWeights(recordingID uint, items []validation.BodyWeight) []entity.RecordingBW { - if len(items) == 0 { - return nil - } - - result := make([]entity.RecordingBW, 0, len(items)) - for _, item := range items { - var totalWeight float64 - if item.TotalWeight != nil { - totalWeight = *item.TotalWeight - } - if totalWeight <= 0 { - totalWeight = item.AvgWeight * item.Qty - } - - result = append(result, entity.RecordingBW{ - RecordingId: recordingID, - AvgWeight: item.AvgWeight, - Qty: item.Qty, - TotalWeight: totalWeight, - }) - } - return result -} - func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingStock { if len(items) == 0 { return nil @@ -86,20 +61,3 @@ func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity. } return result } - -func ToGrams(weight float64) float64 { - if weight <= 0 { - return 0 - } - if weight < 10 { - return weight * 1000 - } - return weight -} - -func GramsToKg(grams float64) float64 { - if grams <= 0 { - return 0 - } - return grams / 1000 -} From 8e7e97694603f298f02989b9a9c85719cde5a5f6 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 29 Dec 2025 16:25:08 +0700 Subject: [PATCH 02/29] feat(BE-281): adjustment recording table with handhouse and deleting weight unfinished dto:standart fcr,hand house and others --- ...ng_egg_and_deleting_recording_bws.down.sql | 1 - ...ding_egg_and_deleting_recording_bws.up.sql | 4 +- internal/entities/recording.go | 7 +- internal/entities/recording_bw.go | 15 -- .../recordings/dto/recording.dto.go | 48 ++++-- .../repositories/recording.repository.go | 158 ++++++------------ .../recordings/services/recording.service.go | 90 ++++++++-- 7 files changed, 165 insertions(+), 158 deletions(-) delete mode 100644 internal/entities/recording_bw.go diff --git a/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql index a52551bc..c42fd7d6 100644 --- a/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql +++ b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql @@ -44,7 +44,6 @@ ALTER TABLE recording_eggs DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; ALTER TABLE recording_eggs - DROP COLUMN IF EXISTS fcr_value, ALTER COLUMN weight TYPE NUMERIC(10,3) USING weight::NUMERIC(10,3); ALTER TABLE recording_eggs diff --git a/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql index 3617a71b..032d77b5 100644 --- a/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql +++ b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql @@ -27,7 +27,6 @@ ALTER TABLE recordings ); ALTER TABLE recording_eggs - ADD COLUMN IF NOT EXISTS fcr_value NUMERIC(15,3), ALTER COLUMN weight TYPE NUMERIC(15,3) USING weight::NUMERIC(15,3); ALTER TABLE recording_eggs @@ -36,8 +35,7 @@ ALTER TABLE recording_eggs ALTER TABLE recording_eggs ADD CONSTRAINT chk_recording_eggs_qty CHECK ( qty >= 0 AND - (weight IS NULL OR weight >= 0) AND - (fcr_value IS NULL OR fcr_value >= 0) + (weight IS NULL OR weight >= 0) ); DROP INDEX IF EXISTS idx_recording_bws_recording; diff --git a/internal/entities/recording.go b/internal/entities/recording.go index 7b4497e3..404c4986 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -13,11 +13,14 @@ type Recording struct { Day *int `gorm:"column:day"` TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"` CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` - DailyGain *float64 `gorm:"column:daily_gain"` - AvgDailyGain *float64 `gorm:"column:avg_daily_gain"` CumIntake *int `gorm:"column:cum_intake"` FcrValue *float64 `gorm:"column:fcr_value"` TotalChickQty *float64 `gorm:"column:total_chick_qty"` + HandDay *float64 `gorm:"column:hand_day"` + HandHouse *float64 `gorm:"column:hand_house"` + FeedIntake *float64 `gorm:"column:feed_intake"` + EggMesh *float64 `gorm:"column:egg_mesh"` + EggWeight *float64 `gorm:"column:egg_weight"` CreatedBy uint `gorm:"column:created_by"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/recording_bw.go b/internal/entities/recording_bw.go deleted file mode 100644 index 041df0f6..00000000 --- a/internal/entities/recording_bw.go +++ /dev/null @@ -1,15 +0,0 @@ -package entities - -import "time" - -type RecordingBW struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null;index"` - AvgWeight float64 `gorm:"column:avg_weight;not null"` - Qty float64 `gorm:"column:qty;not null"` - TotalWeight float64 `gorm:"column:total_weight;not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - - Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` -} diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 53106d84..d38642b9 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -22,11 +22,14 @@ type RecordingRelationDTO struct { ProjectFlockCategory string `json:"project_flock_category"` TotalDepletionQty float64 `json:"total_depletion_qty"` CumDepletionRate float64 `json:"cum_depletion_rate"` - DailyGain float64 `json:"daily_gain"` - AvgDailyGain float64 `json:"avg_daily_gain"` CumIntake int `json:"cum_intake"` FcrValue float64 `json:"fcr_value"` TotalChickQty float64 `json:"total_chick_qty"` + HandDay float64 `json:"hand_day"` + HandHouse float64 `json:"hand_house"` + FeedIntake float64 `json:"feed_intake"` + EggMesh float64 `json:"egg_mesh"` + EggWeight float64 `json:"egg_weight"` Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } @@ -39,9 +42,9 @@ type RecordingListDTO struct { type RecordingDetailDTO struct { RecordingListDTO - Depletions []RecordingDepletionDTO `json:"depletions"` - Stocks []RecordingStockDTO `json:"stocks"` - Eggs []RecordingEggDTO `json:"eggs"` + Depletions []RecordingDepletionDTO `json:"depletions"` + Stocks []RecordingStockDTO `json:"stocks"` + Eggs []RecordingEggDTO `json:"eggs"` } type RecordingDepletionDTO struct { @@ -81,11 +84,14 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { day int totalDepletionQty float64 cumDepletionRate float64 - dailyGain float64 - avgDailyGain float64 cumIntake int fcrValue float64 totalChickQty float64 + handDay float64 + handHouse float64 + feedIntake float64 + eggMesh float64 + eggWeight float64 ) if e.Day != nil { @@ -97,12 +103,6 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { if e.CumDepletionRate != nil { cumDepletionRate = *e.CumDepletionRate } - if e.DailyGain != nil { - dailyGain = *e.DailyGain - } - if e.AvgDailyGain != nil { - avgDailyGain = *e.AvgDailyGain - } if e.CumIntake != nil { cumIntake = *e.CumIntake } @@ -112,6 +112,21 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { if e.TotalChickQty != nil { totalChickQty = *e.TotalChickQty } + if e.HandDay != nil { + handDay = *e.HandDay + } + if e.HandHouse != nil { + handHouse = *e.HandHouse + } + if e.FeedIntake != nil { + feedIntake = *e.FeedIntake + } + if e.EggMesh != nil { + eggMesh = *e.EggMesh + } + if e.EggWeight != nil { + eggWeight = *e.EggWeight + } if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { category := e.ProjectFlockKandang.ProjectFlock.Category @@ -132,11 +147,14 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { ProjectFlockCategory: projectFlockCategory, TotalDepletionQty: totalDepletionQty, CumDepletionRate: cumDepletionRate, - DailyGain: dailyGain, - AvgDailyGain: avgDailyGain, CumIntake: cumIntake, FcrValue: fcrValue, TotalChickQty: totalChickQty, + HandDay: handDay, + HandHouse: handHouse, + FeedIntake: feedIntake, + EggMesh: eggMesh, + EggWeight: eggWeight, Approval: latestApproval, } } diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index a273d88c..8642ed08 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -19,9 +19,6 @@ type RecordingRepository interface { WithRelations(db *gorm.DB) *gorm.DB GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) - CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error - DeleteBodyWeights(tx *gorm.DB, recordingID uint) error - CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error DeleteStocks(tx *gorm.DB, recordingID uint) error ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) @@ -41,10 +38,10 @@ type RecordingRepository interface { SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) - GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) + GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) - GetFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) - GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) + GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) + GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) @@ -91,17 +88,6 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda return nextRecordingDay(days), nil } -func (r *RecordingRepositoryImpl) CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error { - if len(bodyWeights) == 0 { - return nil - } - return tx.Create(&bodyWeights).Error -} - -func (r *RecordingRepositoryImpl) DeleteBodyWeights(tx *gorm.DB, recordingID uint) error { - return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingBW{}).Error -} - func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error { if len(stocks) == 0 { return nil @@ -270,21 +256,18 @@ func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandang return int64(math.Round(total)), nil } -func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) { - var result struct { - TotalWeight float64 - TotalQty float64 - } - if err := tx.Model(&entity.RecordingBW{}). - Select("COALESCE(SUM(total_weight), 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 { +func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) { + if projectFlockKandangId == 0 { return 0, nil } - return result.TotalWeight / result.TotalQty, nil + + var result float64 + err := tx. + Table("project_chickins"). + Select("COALESCE(SUM(project_chickins.usage_qty), 0)"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId). + Scan(&result).Error + return result, err } func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { @@ -321,89 +304,52 @@ func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID u return total, nil } -func (r *RecordingRepositoryImpl) GetFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) { - var result struct { - FcrID uint - } - if err := tx.Table("project_flock_kandangs"). - Select("project_flocks.fcr_id AS fcr_id"). - Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). - Where("project_flock_kandangs.id = ?", projectFlockKandangId). - Scan(&result).Error; err != nil { - return 0, err - } - return result.FcrID, nil -} - -func (r *RecordingRepositoryImpl) GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) { - if fcrId == 0 { - return 0, false, nil - } - - var standard entity.FcrStandard - err := tx. - Where("fcr_id = ? AND weight >= ?", fcrId, currentWeightKg). - Order("weight ASC"). - First(&standard).Error - - if errors.Is(err, gorm.ErrRecordNotFound) { - err = tx. - Where("fcr_id = ?", fcrId). - Order("weight DESC"). - First(&standard).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, false, nil - } - } - if err != nil { - return 0, false, err - } - - weight := standard.Weight - if weight > 10 { - return weight / 1000, true, nil - } - return weight, true, nil -} - -func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) { - if projectFlockID == 0 { +func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) { + if recordingID == 0 { return 0, 0, nil } - totalChickinQty, err := r.getTotalChickinQtyByProjectFlockID(ctx, projectFlockID) + var result struct { + TotalQty float64 + TotalWeightGrams float64 + } + err = tx. + Table("recording_eggs"). + Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(recording_eggs.qty * COALESCE(recording_eggs.weight, 0)), 0) AS total_weight_grams"). + Where("recording_eggs.recording_id = ?", recordingID). + Scan(&result).Error if err != nil { return 0, 0, err } - - totalDepletion, err := r.GetTotalDepletionByProjectFlockID(ctx, projectFlockID) - if err != nil { - return 0, 0, err - } - - actualQty := totalChickinQty - totalDepletion - - avgWeight, err := r.GetLatestAvgWeightByProjectFlockID(ctx, projectFlockID) - if err != nil { - return 0, 0, err - } - - totalWeight = actualQty * avgWeight - - return totalWeight, actualQty, nil + return result.TotalQty, result.TotalWeightGrams, nil } -func (r *RecordingRepositoryImpl) getTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { +func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang( + tx *gorm.DB, + projectFlockKandangId uint, + recordTime time.Time, +) (float64, error) { + if projectFlockKandangId == 0 { + return 0, nil + } + var result float64 - err := r.DB().WithContext(ctx). - Table("project_chickins"). - Select("COALESCE(SUM(project_chickins.usage_qty), 0)"). - Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = project_chickins.project_flock_kandang_id"). - Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + err := tx. + Table("recording_eggs"). + Select("COALESCE(SUM(recording_eggs.qty), 0)"). + Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). + Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("recordings.record_datetime <= ?", recordTime). Scan(&result).Error return result, err } +func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) { + // Body-weight tracking is removed; keep stub for report compatibility. + return 0, 0, nil +} + + func (r *RecordingRepositoryImpl) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { var result float64 err := r.DB().WithContext(ctx). @@ -417,16 +363,8 @@ func (r *RecordingRepositoryImpl) GetTotalDepletionByProjectFlockID(ctx context. } func (r *RecordingRepositoryImpl) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { - var result float64 - err := r.DB().WithContext(ctx). - Table("recording_bws"). - Select("COALESCE(AVG(recording_bws.avg_weight), 0)"). - Joins("JOIN recordings ON recordings.id = recording_bws.recording_id"). - Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). - Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). - Where("recordings.record_datetime = (SELECT MAX(record_datetime) FROM recordings r2 WHERE r2.project_flock_kandangs_id IN (SELECT id FROM project_flock_kandangs WHERE project_flock_id = ?))", projectFlockID). - Scan(&result).Error - return result, err + // Body-weight tracking is removed; keep stub for report compatibility. + return 0, nil } func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index d7f2c3e0..2098aad6 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -255,7 +255,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, nil, nil, mappedEggs)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err } @@ -384,7 +384,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil, nil, nil)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) return err } @@ -408,7 +408,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, nil, nil, existingEggs, mappedEggs)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) return err } @@ -578,7 +578,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, nil, nil, oldEggs, nil)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil { return err } @@ -706,7 +706,6 @@ func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm. func buildWarehouseDeltas( oldDepletions, newDepletions []entity.RecordingDepletion, - oldStocks, newStocks []entity.RecordingStock, oldEggs, newEggs []entity.RecordingEgg, ) map[uint]float64 { deltas := make(map[uint]float64) @@ -776,6 +775,21 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return fmt.Errorf("getFeedUsageInGrams: %w", err) } + totalEggQty, totalEggWeightGrams, err := s.Repository.GetEggSummaryByRecording(tx, recording.Id) + if err != nil { + return fmt.Errorf("getEggSummaryByRecording: %w", err) + } + + cumulativeEggQty, err := s.Repository.GetCumulativeEggQtyByProjectFlockKandang(tx, recording.ProjectFlockKandangId, recording.RecordDatetime) + if err != nil { + return fmt.Errorf("getCumulativeEggQtyByProjectFlockKandang: %w", err) + } + + initialChickin, err := s.Repository.GetTotalChickinByProjectFlockKandang(tx, recording.ProjectFlockKandangId) + if err != nil { + return fmt.Errorf("getTotalChickinByProjectFlockKandang: %w", err) + } + currentDepletion := float64(totalDepletionQty) cumDepletionQty := prevCumDepletionQty + currentDepletion @@ -807,10 +821,65 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.CumDepletionRate = nil } - updates["daily_gain"] = gorm.Expr("NULL") - updates["avg_daily_gain"] = gorm.Expr("NULL") - recording.DailyGain = nil - recording.AvgDailyGain = nil + var feedIntake float64 + if remainingChick > 0 && usageInGrams > 0 { + feedIntake = (usageInGrams / remainingChick) * 1000 + updates["feed_intake"] = feedIntake + recording.FeedIntake = &feedIntake + } else { + updates["feed_intake"] = gorm.Expr("NULL") + recording.FeedIntake = nil + } + + var handDay float64 + if remainingChick > 0 && totalEggQty >= 0 { + handDay = (totalEggQty / remainingChick) * 100 + updates["hand_day"] = handDay + recording.HandDay = &handDay + } else { + updates["hand_day"] = gorm.Expr("NULL") + recording.HandDay = nil + } + + var handHouse float64 + if initialChickin > 0 && cumulativeEggQty >= 0 { + handHouse = cumulativeEggQty / initialChickin + updates["hand_house"] = handHouse + recording.HandHouse = &handHouse + } else { + updates["hand_house"] = gorm.Expr("NULL") + recording.HandHouse = nil + } + + var eggMesh float64 + if remainingChick > 0 && totalEggWeightGrams > 0 { + eggMesh = (totalEggWeightGrams / remainingChick) * 1000 + updates["egg_mesh"] = eggMesh + recording.EggMesh = &eggMesh + } else { + updates["egg_mesh"] = gorm.Expr("NULL") + recording.EggMesh = nil + } + + var eggWeight float64 + if totalEggQty > 0 && totalEggWeightGrams > 0 { + eggWeight = (totalEggWeightGrams / totalEggQty) * 1000 + updates["egg_weight"] = eggWeight + recording.EggWeight = &eggWeight + } else { + updates["egg_weight"] = gorm.Expr("NULL") + recording.EggWeight = nil + } + + var fcrValue float64 + if usageInGrams > 0 && totalEggWeightGrams > 0 { + fcrValue = totalEggWeightGrams / usageInGrams + updates["fcr_value"] = fcrValue + recording.FcrValue = &fcrValue + } else { + updates["fcr_value"] = gorm.Expr("NULL") + recording.FcrValue = nil + } if usageInGrams > 0 && totalChick > 0 { var cumIntakeValue float64 @@ -834,9 +903,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.CumIntake = nil } - updates["fcr_value"] = gorm.Expr("NULL") - recording.FcrValue = nil - if err := s.Repository.WithTx(tx).PatchOne(ctx, recording.Id, updates, nil); err != nil { return err } From 756ba223edbef6e0630b36ee0b6b0c2ce51fa47f Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 30 Dec 2025 13:17:01 +0700 Subject: [PATCH 03/29] feat(BE-281):add standart production into response recording get one --- internal/entities/recording.go | 6 ++ .../recordings/dto/recording.dto.go | 10 ++ .../recordings/services/recording.service.go | 93 ++++++++++++++++++- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/internal/entities/recording.go b/internal/entities/recording.go index 404c4986..e07a0a6b 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -33,4 +33,10 @@ type Recording struct { Eggs []RecordingEgg `gorm:"foreignKey:RecordingId;references:Id"` LatestApproval *Approval `gorm:"-" json:"-"` + + StandardHandDay *float64 `gorm:"-"` + StandardHandHouse *float64 `gorm:"-"` + StandardFeedIntake *float64 `gorm:"-"` + StandardEggMesh *float64 `gorm:"-"` + StandardEggWeight *float64 `gorm:"-"` } diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index d38642b9..12222b97 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -30,6 +30,11 @@ type RecordingRelationDTO struct { FeedIntake float64 `json:"feed_intake"` EggMesh float64 `json:"egg_mesh"` EggWeight float64 `json:"egg_weight"` + StandardHandDay *float64 `json:"hand_day_std,omitempty"` + StandardHandHouse *float64 `json:"hand_house_std,omitempty"` + StandardFeedIntake *float64 `json:"feed_intake_std,omitempty"` + StandardEggMesh *float64 `json:"egg_mesh_std,omitempty"` + StandardEggWeight *float64 `json:"egg_weight_std,omitempty"` Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } @@ -155,6 +160,11 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { FeedIntake: feedIntake, EggMesh: eggMesh, EggWeight: eggWeight, + StandardHandDay: e.StandardHandDay, + StandardHandHouse: e.StandardHandHouse, + StandardFeedIntake: e.StandardFeedIntake, + StandardEggMesh: e.StandardEggMesh, + StandardEggWeight: e.StandardEggWeight, Approval: latestApproval, } } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 2098aad6..a3756adf 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -9,6 +9,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProjectFlock "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" @@ -121,6 +122,9 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if err := s.attachLatestApprovals(c.Context(), recordings); err != nil { return nil, 0, err } + if err := s.attachProductionStandards(c.Context(), recordings); err != nil { + return nil, 0, err + } return recordings, total, nil } @@ -138,6 +142,9 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro if err := s.attachLatestApproval(c.Context(), recording); err != nil { return nil, err } + if err := s.attachProductionStandard(c.Context(), recording); err != nil { + return nil, err + } return recording, nil } @@ -255,7 +262,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err } @@ -384,7 +391,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) return err } @@ -408,7 +415,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) return err } @@ -578,7 +585,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil { return err } @@ -1008,6 +1015,84 @@ func (s *recordingService) attachLatestApproval(ctx context.Context, item *entit return nil } +type productionStandardValues struct { + HandDay *float64 + HandHouse *float64 + FeedIntake *float64 + EggMesh *float64 + EggWeight *float64 +} + +func (s *recordingService) attachProductionStandards(ctx context.Context, items []entity.Recording) error { + if len(items) == 0 { + return nil + } + + for i := range items { + if err := s.attachProductionStandard(ctx, &items[i]); err != nil { + s.Log.Warnf("Unable to load production standard for recording %d: %+v", items[i].Id, err) + } + } + return nil +} + +func (s *recordingService) attachProductionStandard(ctx context.Context, item *entity.Recording) error { + if item == nil || item.Id == 0 { + return nil + } + if item.Day == nil || *item.Day <= 0 { + return nil + } + if item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { + return nil + } + + standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId + if standardID == 0 { + return nil + } + + week := ((int(*item.Day) - 1) / 7) + 1 + if week <= 0 { + return nil + } + + category := strings.ToUpper(item.ProjectFlockKandang.ProjectFlock.Category) + db := s.Repository.DB() + standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + + var standard productionStandardValues + if category == string(utils.ProjectFlockCategoryLaying) { + detail, err := standardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if detail != nil { + standard.HandDay = detail.TargetHenDayProduction + standard.HandHouse = detail.TargetHenHouseProduction + standard.EggWeight = detail.TargetEggWeight + standard.EggMesh = detail.TargetEggMass + } + } + + growthDetail, err := growthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if growthDetail != nil { + standard.FeedIntake = growthDetail.FeedIntake + } + + item.StandardHandDay = standard.HandDay + item.StandardHandHouse = standard.HandHouse + item.StandardFeedIntake = standard.FeedIntake + item.StandardEggMesh = standard.EggMesh + item.StandardEggWeight = standard.EggWeight + + return nil +} + func uniqueUintSlice(values []uint) []uint { if len(values) == 0 { return nil From 0396aa02554624fd89f45b3ea3a9d42a71bae93e Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 30 Dec 2025 14:27:50 +0700 Subject: [PATCH 04/29] feat(BE-287):adjustment purchase restrict unfinished --- .../purchases/services/expense_bridge.go | 44 +++++++++++-------- .../purchases/services/purchase.service.go | 31 +++++++------ 2 files changed, 43 insertions(+), 32 deletions(-) diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 146f04f2..70a06c92 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -310,9 +310,6 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ return err } if cnt == 1 { - if item.Warehouse == nil || item.Warehouse.KandangId == nil || *item.Warehouse.KandangId == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") - } newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) if err != nil { return err @@ -332,7 +329,9 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ "price": pricePerItem, "notes": note, "nonstock_id": newNonstockID, - "kandang_id": uint64(*item.Warehouse.KandangId), + } + if item.Warehouse != nil && item.Warehouse.KandangId != nil && *item.Warehouse.KandangId != 0 { + updateBody["kandang_id"] = uint64(*item.Warehouse.KandangId) } if err := b.db.WithContext(ctx). Model(&entity.ExpenseNonstock{}). @@ -550,18 +549,27 @@ func (b *expenseBridge) createExpenseViaService( } kandangID := items[0].kandangID - if kandangID == nil || *kandangID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") - } - - kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB { - return db.Select("id, location_id") - }) - if err != nil { - return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) - } - if kandang == nil { - return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + var locationID uint64 + var expenseKandangID *uint64 + if kandangID != nil && *kandangID != 0 { + kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB { + return db.Select("id, location_id") + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + } + if kandang == nil { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + } + locationID = uint64(kandang.LocationId) + id := uint64(*kandangID) + expenseKandangID = &id + } else { + warehouse := items[0].item.Warehouse + if warehouse == nil || warehouse.LocationId == nil || *warehouse.LocationId == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse location is required for expense") + } + locationID = uint64(*warehouse.LocationId) } costItems := make([]expenseValidation.CostItem, 0, len(items)) @@ -584,9 +592,9 @@ func (b *expenseBridge) createExpenseViaService( TransactionDate: utils.FormatDate(expenseDate), Category: "BOP", SupplierID: uint64(supplierID), - LocationID: uint64(kandang.LocationId), + LocationID: locationID, ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ - KandangID: func() *uint64 { id := uint64(*kandangID); return &id }(), + KandangID: expenseKandangID, CostItems: costItems, }}, } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 366a8c0e..43c2bdc7 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -246,22 +246,25 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase s.Log.Errorf("Failed to get warehouse %d: %+v", id, err) return nil, nil, utils.Internal("Failed to get warehouse") } - if warehouse.KandangId == nil || *warehouse.KandangId == 0 { - return nil, nil, utils.BadRequest(fmt.Sprintf("%s is not linked to a kandang", warehouse.Name)) - } var pfkID *uint - if s.ProjectFlockKandangRepo != nil { - if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil { - if pfk.ClosedAt != nil { - return nil, nil, utils.BadRequest("Project sudah closing") + isKandang := strings.EqualFold(strings.TrimSpace(warehouse.Type), "KANDANG") + if isKandang { + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return nil, nil, utils.BadRequest(fmt.Sprintf("%s is not linked to a kandang", warehouse.Name)) + } + if s.ProjectFlockKandangRepo != nil { + if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil { + if pfk.ClosedAt != nil { + return nil, nil, utils.BadRequest("Project sudah closing") + } + idCopy := uint(pfk.Id) + pfkID = &idCopy + } else if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, utils.BadRequest(fmt.Sprintf("%s has no active project flock", warehouse.Name)) + } else if err != nil { + s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err) + return nil, nil, utils.Internal("Failed to validate project flock") } - idCopy := uint(pfk.Id) - pfkID = &idCopy - } else if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil, utils.BadRequest(fmt.Sprintf("%s has no active project flock", warehouse.Name)) - } else if err != nil { - s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err) - return nil, nil, utils.Internal("Failed to validate project flock") } } From b988f45a0b402c3a8d42a13131293d1b03641f56 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 30 Dec 2025 19:30:42 +0700 Subject: [PATCH 05/29] feat(BE): update expense DTO and service to directly use location from expense --- internal/modules/expenses/dto/expense.dto.go | 8 +++----- .../modules/expenses/services/expense.service.go | 12 +++++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 6402f8fd..129c2e96 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -105,11 +105,9 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { realizationDate = &e.RealizationDate } - if len(e.Nonstocks) > 0 && e.Nonstocks[0].Kandang != nil { - if e.Nonstocks[0].Kandang.Location.Id != 0 { - mapped := locationDTO.ToLocationRelationDTO(e.Nonstocks[0].Kandang.Location) - location = &mapped - } + if e.Location != nil && e.Location.Id != 0 { + mapped := locationDTO.ToLocationRelationDTO(*e.Location) + location = &mapped } if e.Supplier != nil && e.Supplier.Id != 0 { diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index b4753451..20d6b568 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -30,7 +30,7 @@ type ExpenseService interface { GetOne(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error) - DeleteOne(ctx *fiber.Ctx, id uint) error + DeleteOne(ctx *fiber.Ctx, id uint64) error CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) @@ -68,6 +68,7 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("Supplier"). + Preload("Location"). Preload("Nonstocks.Nonstock"). Preload("Nonstocks.Realization"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). @@ -621,14 +622,15 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return responseDTO, nil } -func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error { +func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error { + idUint := uint(id) if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, + commonSvc.RelationCheck{Name: "Expense", ID: &idUint, Exists: s.Repository.IdExists}, ); err != nil { return err } - expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + expense, err := s.Repository.GetByID(c.Context(), idUint, func(db *gorm.DB) *gorm.DB { return db.Preload("Nonstocks") }) if err != nil { @@ -643,7 +645,7 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error { if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { return err } - if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if err := s.Repository.DeleteOne(c.Context(), idUint); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Expense not found for ID %d: %+v", id, err) return fiber.NewError(fiber.StatusNotFound, "Expense not found") From 3ecea6741f6ded980be7963e7593f19f97e6d312 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 30 Dec 2025 19:39:10 +0700 Subject: [PATCH 06/29] feat(BE): update DeleteOne method to use uint64 for ID and implement soft delete logic --- .../controllers/expense.controller.go | 4 ++-- .../repositories/expense.repository.go | 23 ++++++++++++++++++ internal/modules/expenses/route.go | 24 +++++++++---------- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 49642231..666642ca 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -203,12 +203,12 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { func (u *ExpenseController) DeleteOne(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.Atoi(param) + id64, err := strconv.ParseUint(param, 10, 64) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } - if err := u.ExpenseService.DeleteOne(c, uint(id)); err != nil { + if err := u.ExpenseService.DeleteOne(c, id64); err != nil { return err } diff --git a/internal/modules/expenses/repositories/expense.repository.go b/internal/modules/expenses/repositories/expense.repository.go index 844a6409..8796c761 100644 --- a/internal/modules/expenses/repositories/expense.repository.go +++ b/internal/modules/expenses/repositories/expense.repository.go @@ -3,6 +3,8 @@ package repository import ( "context" "errors" + "fmt" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -17,6 +19,7 @@ type ExpenseRepository interface { GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error) WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) + DeleteOne(ctx context.Context, id uint) error } type ExpenseRepositoryImpl struct { @@ -107,3 +110,23 @@ func (r *ExpenseRepositoryImpl) CountUnfinishedByProjectFlockKandang(ctx context } return unfinished, nil } + +func (r *ExpenseRepositoryImpl) DeleteOne(ctx context.Context, id uint) error { + // Cast to uint64 to match entity.Id type + id64 := uint64(id) + deletedAt := time.Now() + + // Use raw SQL with interpolated integer to avoid type issues + // Interpolate id directly as integer literal (safe because it's uint64) + result := r.DB().WithContext(ctx). + Exec(`UPDATE "expenses" SET "deleted_at" = $1 WHERE "id" = `+fmt.Sprintf("%d", id64)+` AND "deleted_at" IS NULL`, + deletedAt) + + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index fa3191fa..9c22bde3 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -22,16 +22,16 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - route.Get("/",m.RequirePermissions(m.P_ExpenseGetAll), ctrl.GetAll) - route.Post("/",m.RequirePermissions(m.P_ExpenseCreateOne), ctrl.CreateOne) - route.Get("/:id",m.RequirePermissions(m.P_ExpenseGetOne), ctrl.GetOne) - route.Patch("/:id",m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne) - route.Delete("/:id",m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne) - route.Post("/approvals/manager",m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval) - route.Post("/approvals/finance",m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) - route.Post("/:id/realizations",m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) - route.Patch("/:id/realizations",m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) - route.Post("/:id/complete",m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense) - route.Delete("/:id/documents/:documentId",m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument) - route.Delete("/:id/realization-documents/:documentId",m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument) + route.Get("/", m.RequirePermissions(m.P_ExpenseGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_ExpenseCreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_ExpenseGetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne) + route.Post("/approvals/manager", m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval) + route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) + route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) + route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) + route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense) + route.Delete("/:id/documents/:documentId", m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument) + route.Delete("/:id/realization-documents/:documentId", m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument) } From d91ff7a4c295385ea06c1d9b73f2babba68d9bec Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 30 Dec 2025 20:03:23 +0700 Subject: [PATCH 07/29] feat(BE): add supplier_id filter to GetAll method and update validation for query parameters --- .../modules/master/nonstocks/services/nonstock.service.go | 8 ++++++++ .../master/nonstocks/validations/nonstock.validation.go | 7 ++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/modules/master/nonstocks/services/nonstock.service.go b/internal/modules/master/nonstocks/services/nonstock.service.go index c0001a52..e201b1f1 100644 --- a/internal/modules/master/nonstocks/services/nonstock.service.go +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -59,6 +59,14 @@ func (s nonstockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit nonstocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + + if params.SupplierID != nil { + supplierID := *params.SupplierID + db = db.Joins("JOIN nonstock_suppliers ON nonstock_suppliers.nonstock_id = nonstocks.id"). + Where("nonstock_suppliers.supplier_id = ?", supplierID). + Group("nonstocks.id") // Prevent duplicates from join + } + if params.Search != "" { return db.Where("name LIKE ?", "%"+params.Search+"%") } diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index c421b7ec..b58370d5 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -15,7 +15,8 @@ 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"` - 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"` + Search string `query:"search" validate:"omitempty,max=50"` + SupplierID *uint `query:"supplier_id" validate:"omitempty,gt=0"` } From 91fd8a253bae28486425bde9850279bbf3c2eb4a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 30 Dec 2025 20:16:40 +0700 Subject: [PATCH 08/29] feat(BE): update foreign key constraints for project_chickins and adjust service logic for project flock kandang retrieval --- ...alter_project_chickins_fk_cascade.down.sql | 20 +++++++++++++++++++ ...9_alter_project_chickins_fk_cascade.up.sql | 20 +++++++++++++++++++ .../services/adjustment.service.go | 7 +++---- 3 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.down.sql create mode 100644 internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.up.sql diff --git a/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.down.sql b/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.down.sql new file mode 100644 index 00000000..1314087c --- /dev/null +++ b/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.down.sql @@ -0,0 +1,20 @@ +-- Drop CASCADE constraint +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_chickins_kandang' + AND conrelid = 'project_chickins'::regclass + ) THEN + ALTER TABLE project_chickins + DROP CONSTRAINT fk_project_chickins_kandang; + END IF; +END $$; + +-- Recreate foreign key constraint with RESTRICT (original behavior) +ALTER TABLE project_chickins +ADD CONSTRAINT fk_project_chickins_kandang +FOREIGN KEY (project_flock_kandang_id) +REFERENCES project_flock_kandangs(id) +ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.up.sql b/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.up.sql new file mode 100644 index 00000000..ad07b8e0 --- /dev/null +++ b/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.up.sql @@ -0,0 +1,20 @@ +-- Drop existing foreign key constraint with RESTRICT +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_chickins_kandang' + AND conrelid = 'project_chickins'::regclass + ) THEN + ALTER TABLE project_chickins + DROP CONSTRAINT fk_project_chickins_kandang; + END IF; +END $$; + +-- Add new foreign key constraint with CASCADE delete +ALTER TABLE project_chickins +ADD CONSTRAINT fk_project_chickins_kandang +FOREIGN KEY (project_flock_kandang_id) +REFERENCES project_flock_kandangs(id) +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index edf5f72b..f15f37df 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -118,10 +118,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e var createdLogId uint var projectFlockKandangID *uint - pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(req.WarehouseID)) - if err == nil && pfk != nil { - idCopy := uint(pfk.Id) - projectFlockKandangID = &idCopy + pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) + if err == nil && pfkID > 0 { + projectFlockKandangID = &pfkID } pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk( From fd5f83ca58cc1c140a6e6977bee98f6f73b5031c Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 03:50:58 +0700 Subject: [PATCH 09/29] feat(BE-278): unrestrict feat warehouse purchase,adding purchase upload document --- internal/middleware/permissions.go | 1 + .../expenses/services/expense.service.go | 24 +++-- .../product_warehouse.repository.go | 25 +++++ .../controllers/purchase.controller.go | 36 +++++-- internal/modules/purchases/module.go | 1 + .../purchases/services/expense_bridge.go | 4 + .../purchases/services/purchase.service.go | 98 ++++++++++++++----- .../validations/purchase.validation.go | 29 +++--- internal/modules/repports/route.go | 2 +- internal/utils/constant.go | 2 + 10 files changed, 159 insertions(+), 63 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index f0056149..1d308787 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -44,6 +44,7 @@ const ( P_ReportExpenseGetAll = "lti.repport.expense.list" P_ReportDeliveryGetAll = "lti.repport.delivery.list" P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list" + P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" ) const ( diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index b4753451..37d4cec0 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -214,21 +214,19 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location") } - if len(activeProjectFlocks) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "No active project flocks found for this location") - } + if len(activeProjectFlocks) > 0 { + projectFlockIDs := make([]uint64, len(activeProjectFlocks)) + for i, pf := range activeProjectFlocks { + projectFlockIDs[i] = uint64(pf.Id) + } - projectFlockIDs := make([]uint64, len(activeProjectFlocks)) - for i, pf := range activeProjectFlocks { - projectFlockIDs[i] = uint64(pf.Id) + projectFlockIdsJSON, err := json.Marshal(projectFlockIDs) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids") + } + jsonStr := string(projectFlockIdsJSON) + projectFlockIdJSON = &jsonStr } - - projectFlockIdsJSON, err := json.Marshal(projectFlockIDs) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids") - } - jsonStr := string(projectFlockIdsJSON) - projectFlockIdJSON = &jsonStr } expense = &entity.Expense{ 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 e759138e..3cb22851 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -199,6 +199,31 @@ func (r *ProductWarehouseRepositoryImpl) CleanupEmpty(ctx context.Context, affec return nil } + var inUseIDs []uint + if err := r.DB().WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("product_warehouse_id IN ?", emptyIDs). + Distinct(). + Pluck("product_warehouse_id", &inUseIDs).Error; err != nil { + return err + } + if len(inUseIDs) > 0 { + inUse := make(map[uint]struct{}, len(inUseIDs)) + for _, id := range inUseIDs { + inUse[id] = struct{}{} + } + filtered := make([]uint, 0, len(emptyIDs)) + for _, id := range emptyIDs { + if _, exists := inUse[id]; !exists { + filtered = append(filtered, id) + } + } + emptyIDs = filtered + } + if len(emptyIDs) == 0 { + return nil + } + if err := r.DB().WithContext(ctx). Model(&entity.PurchaseItem{}). Where("product_warehouse_id IN ?", emptyIDs). diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index b4cf5660..d9b32cd1 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/json" "fmt" "math" "strconv" @@ -24,13 +25,13 @@ func NewPurchaseController(s service.PurchaseService) *PurchaseController { func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - CreatedFrom: strings.TrimSpace(c.Query("created_from")), - CreatedTo: strings.TrimSpace(c.Query("created_to")), - SupplierID: uint(c.QueryInt("supplier_id", 0)), - AreaID: uint(c.QueryInt("area_id", 0)), - LocationID: uint(c.QueryInt("location_id", 0)), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + CreatedFrom: strings.TrimSpace(c.Query("created_from")), + CreatedTo: strings.TrimSpace(c.Query("created_to")), + SupplierID: uint(c.QueryInt("supplier_id", 0)), + AreaID: uint(c.QueryInt("area_id", 0)), + LocationID: uint(c.QueryInt("location_id", 0)), ProductCategoryID: uint(c.QueryInt("product_category_id", 0)), } @@ -86,7 +87,6 @@ func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error { if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - result, err := ctrl.service.CreateOne(c, req) if err != nil { return err @@ -161,10 +161,26 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { } req := new(validation.ReceivePurchaseRequest) - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + req.Action = c.FormValue("action") + if notes := strings.TrimSpace(c.FormValue("notes")); notes != "" { + req.Notes = ¬es } + itemsJSON := c.FormValue("items") + if strings.TrimSpace(itemsJSON) != "" { + if err := json.Unmarshal([]byte(itemsJSON), &req.Items); err != nil { + var singleItem validation.ReceivePurchaseItemRequest + if err := json.Unmarshal([]byte(itemsJSON), &singleItem); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid items JSON") + } + req.Items = []validation.ReceivePurchaseItemRequest{singleItem} + } + } + req.TravelDocuments = form.File["documents"] result, err := ctrl.service.ReceiveProducts(c, uint(id), req) if err != nil { return err diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index fa10559d..7e80de38 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -98,6 +98,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalService, expenseBridge, fifoService, + documentSvc, ) userRepo := rUser.NewUserRepository(db) diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 70a06c92..6c74a1fc 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -394,9 +394,13 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ } if kandangID != nil { updateBody["kandang_id"] = uint64(*kandangID) + } else { + updateBody["kandang_id"] = nil } if projectFK != nil { updateBody["project_flock_kandang_id"] = uint64(*projectFK) + } else { + updateBody["project_flock_kandang_id"] = nil } if err := b.db.WithContext(ctx). diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 43c2bdc7..813fbd6f 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "mime/multipart" "strings" "time" @@ -57,6 +58,7 @@ type purchaseService struct { ApprovalSvc commonSvc.ApprovalService ExpenseBridge PurchaseExpenseBridge FifoSvc commonSvc.FifoService + DocumentSvc commonSvc.DocumentService approvalWorkflow approvalutils.ApprovalWorkflowKey } @@ -76,6 +78,7 @@ func NewPurchaseService( approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, fifoSvc commonSvc.FifoService, + documentSvc commonSvc.DocumentService, ) PurchaseService { return &purchaseService{ Log: utils.Log, @@ -89,6 +92,7 @@ func NewPurchaseService( ApprovalSvc: approvalSvc, ExpenseBridge: expenseBridge, FifoSvc: fifoSvc, + DocumentSvc: documentSvc, approvalWorkflow: utils.ApprovalWorkflowPurchase, } } @@ -615,9 +619,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if err := s.Validate.Struct(req); err != nil { return nil, err } - ctx := c.Context() - action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err @@ -664,6 +666,30 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return updated, nil } + if action == entity.ApprovalActionApproved && len(req.TravelDocuments) > 0 { + if len(req.TravelDocuments) > len(req.Items) { + return nil, utils.BadRequest("Travel documents exceed total receiving items") + } + for idx, file := range req.TravelDocuments { + if file == nil { + continue + } + if idx >= len(req.Items) { + break + } + itemID := req.Items[idx].PurchaseItemID + if itemID == 0 { + return nil, utils.BadRequest("Purchase item id is required for travel document upload") + } + uploadedURL, err := s.uploadTravelDocument(ctx, actorID, itemID, file) + if err != nil { + s.Log.Errorf("Failed to upload travel document for item %d: %+v", itemID, err) + return nil, utils.Internal("Failed to upload travel document") + } + req.Items[idx].TravelDocumentPath = &uploadedURL + } + } + itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { itemMap[purchase.Items[i].Id] = &purchase.Items[i] @@ -807,32 +833,20 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation for _, prep := range prepared { item := prep.item - var oldPWID *uint - if item.ProductWarehouseId != nil { - idCopy := uint(*item.ProductWarehouseId) - oldPWID = &idCopy - } - var newPWID *uint - clearPW := false - // Always ensure PW when qty > 0 so stockable has target. - if prep.receivedQty > 0 { - pwID, err := pwRepoTx.EnsureProductWarehouse( - c.Context(), - uint(item.ProductId), - prep.warehouseID, - item.ProjectFlockKandangId, - purchase.CreatedBy, - ) - if err != nil { - return err - } - newPWID = &pwID - } else if oldPWID != nil { - newPWID = oldPWID - clearPW = true + // Always ensure PW after receiving so linkage stays stable. + pwID, err := pwRepoTx.EnsureProductWarehouse( + c.Context(), + uint(item.ProductId), + prep.warehouseID, + item.ProjectFlockKandangId, + purchase.CreatedBy, + ) + if err != nil { + return err } + newPWID = &pwID deltaQty := prep.receivedQty - item.TotalQty switch { @@ -857,7 +871,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation VehicleNumber: prep.payload.VehicleNumber, ReceivedQty: &qtyCopy, ProductWarehouseID: newPWID, - ClearProductWarehouse: clearPW, + ClearProductWarehouse: false, } if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID { @@ -972,6 +986,38 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return updated, nil } +func (s *purchaseService) uploadTravelDocument( + ctx context.Context, + actorID uint, + itemID uint, + file *multipart.FileHeader, +) (string, error) { + if file == nil { + return "", errors.New("travel document file is required") + } + if s.DocumentSvc == nil { + return "", errors.New("document service not available") + } + + documentFiles := []commonSvc.DocumentFile{{ + File: file, + Type: string(utils.DocumentTypePurchaseTravel), + }} + results, err := s.DocumentSvc.UploadDocuments(ctx, commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypePurchaseItem), + DocumentableID: uint64(itemID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return "", err + } + if len(results) == 0 { + return "", errors.New("upload result is empty") + } + return results[0].URL, nil +} + func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 1637ccaf..564cc96f 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -1,5 +1,7 @@ package validation +import "mime/multipart" + type PurchaseItemPayload struct { WarehouseID uint `json:"warehouse_id" validate:"required,gt=0"` ProductID uint `json:"product_id" validate:"required,gt=0"` @@ -26,7 +28,7 @@ type StaffPurchaseApprovalItem struct { type ApproveStaffPurchaseRequest struct { Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"` - Items []StaffPurchaseApprovalItem `json:"items,omitempty" validate:"omitempty,min=1,dive"` + Items []StaffPurchaseApprovalItem `json:"items" validate:"omitempty,min=1,dive"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } @@ -36,21 +38,22 @@ type ApproveManagerPurchaseRequest struct { } type ReceivePurchaseItemRequest struct { - PurchaseItemID uint `json:"purchase_item_id" validate:"required,gt=0"` - WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"` - ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"` - ExpeditionVendorID *uint `json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"` - TransportPerItem *float64 `json:"transport_per_item,omitempty" validate:"omitempty,gte=0"` - TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"` - TravelDocumentPath *string `json:"travel_document_path" validate:"omitempty,max=255"` - VehicleNumber *string `json:"vehicle_number" validate:"omitempty,max=100"` - ReceivedQty *float64 `json:"received_qty" validate:"omitempty,gte=0"` + PurchaseItemID uint `form:"purchase_item_id" json:"purchase_item_id" validate:"required,gt=0"` + WarehouseID *uint `form:"warehouse_id" json:"warehouse_id" validate:"omitempty,gt=0"` + ReceivedDate string `form:"received_date" json:"received_date" validate:"required,datetime=2006-01-02"` + ExpeditionVendorID *uint `form:"expedition_vendor_id" json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"` + TransportPerItem *float64 `form:"transport_per_item" json:"transport_per_item,omitempty" validate:"omitempty,gte=0"` + TravelNumber *string `form:"travel_number" json:"travel_number" validate:"omitempty,max=100"` + TravelDocumentPath *string `form:"travel_document_path" json:"travel_document_path" validate:"omitempty,max=1024"` + VehicleNumber *string `form:"vehicle_number" json:"vehicle_number" validate:"omitempty,max=100"` + ReceivedQty *float64 `form:"received_qty" json:"received_qty" validate:"omitempty,gte=0"` } type ReceivePurchaseRequest struct { - Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"` - Items []ReceivePurchaseItemRequest `json:"items,omitempty" validate:"omitempty,min=1,dive"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + Action string `form:"action" json:"action" validate:"required,oneof=APPROVED REJECTED"` + Items []ReceivePurchaseItemRequest `form:"items" json:"items" validate:"min=1,dive"` + TravelDocuments []*multipart.FileHeader `form:"travel_documents" json:"-" validate:"omitempty,dive"` + Notes *string `form:"notes" json:"notes,omitempty" validate:"omitempty,max=500"` } type DeletePurchaseItemsRequest struct { diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 707ef878..83f133af 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -18,6 +18,6 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense) route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing) route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) - route.Get("/hpp-per-kandang", ctrl.GetHppPerKandang) + route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll),ctrl.GetHppPerKandang) } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 85b0cc91..b7875605 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -411,10 +411,12 @@ const ( DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" + DocumentTypePurchaseTravel DocumentType = "PURCHASE_TRAVEL_DOCUMENT" DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" DocumentableTypeExpense DocumentableType = "EXPENSE" DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" + DocumentableTypePurchaseItem DocumentableType = "PURCHASE_ITEM" ) // ------------------------------------------------------------------- From bc03c469f24d4c0249e22701d3fea2efc8932928 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 04:00:41 +0700 Subject: [PATCH 10/29] feat(BE-278): add delete document s3 --- .../purchases/services/purchase.service.go | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 813fbd6f..31e55b86 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -1097,6 +1097,10 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del return nil, utils.Internal("Failed to delete purchase items") } + if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil { + return nil, utils.Internal("Failed to delete purchase documents") + } + if len(itemsToDelete) > 0 { if err := s.notifyExpenseItemsDeleted(ctx, purchase.Id, itemsToDelete); err != nil { s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", purchase.Id, err) @@ -1156,6 +1160,10 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { return utils.Internal("Failed to delete purchase") } + if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil { + return utils.Internal("Failed to delete purchase documents") + } + if len(itemsToDelete) > 0 { if err := s.notifyExpenseItemsDeleted(ctx, uint(id), itemsToDelete); err != nil { s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", id, err) @@ -1239,6 +1247,21 @@ func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchas } +func (s *purchaseService) deletePurchaseItemDocuments(ctx context.Context, items []entity.PurchaseItem) error { + if s.DocumentSvc == nil || len(items) == 0 { + return nil + } + for _, item := range items { + if item.Id == 0 { + continue + } + if err := s.DocumentSvc.DeleteByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id), true); err != nil { + return err + } + } + return nil +} + func (s *purchaseService) buildStaffAdjustmentPayload( ctx context.Context, purchase *entity.Purchase, From dbaee7313455b8f237bf03f9698d41adb56ef38e Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 07:50:13 +0700 Subject: [PATCH 11/29] feat(BE-278): fix error purchase product warehouse --- internal/modules/purchases/services/purchase.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 31e55b86..7dac0e19 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -1534,5 +1534,5 @@ func (s *purchaseService) ensureProjectFlockNotClosedForPurchase( return utils.Internal("DB not available for project flock validation") } - return commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(ctx, db, pfkIDs) + return commonSvc.EnsureProjectFlockNotClosedByProjectFlockKandangID(ctx, db, pfkIDs) } From d9afd2913e90d1c93e8806a1f7b69fa8cfdaef7a Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 09:13:55 +0700 Subject: [PATCH 12/29] feat(BE-278): adjustment_recording dto --- internal/entities/recording.go | 1 + .../recordings/dto/recording.dto.go | 2 ++ .../repositories/recording.repository.go | 29 ++++++++++++++++++- .../recordings/services/recording.service.go | 17 +++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/entities/recording.go b/internal/entities/recording.go index e07a0a6b..03388ef2 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -39,4 +39,5 @@ type Recording struct { StandardFeedIntake *float64 `gorm:"-"` StandardEggMesh *float64 `gorm:"-"` StandardEggWeight *float64 `gorm:"-"` + StandardFcr *float64 `gorm:"-"` } diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 12222b97..736eeaa7 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -35,6 +35,7 @@ type RecordingRelationDTO struct { StandardFeedIntake *float64 `json:"feed_intake_std,omitempty"` StandardEggMesh *float64 `json:"egg_mesh_std,omitempty"` StandardEggWeight *float64 `json:"egg_weight_std,omitempty"` + StandardFcr *float64 `json:"fcr_std,omitempty"` Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } @@ -165,6 +166,7 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { StandardFeedIntake: e.StandardFeedIntake, StandardEggMesh: e.StandardEggMesh, StandardEggWeight: e.StandardEggWeight, + StandardFcr: e.StandardFcr, Approval: latestApproval, } } diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 8642ed08..d9e0bc0b 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -42,6 +42,7 @@ type RecordingRepository interface { GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) + GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) @@ -344,12 +345,38 @@ func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang( return result, err } +func (r *RecordingRepositoryImpl) GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) { + if fcrId == 0 || currentWeightKg <= 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 + } + + return standard.FcrNumber, true, nil +} + func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) { // Body-weight tracking is removed; keep stub for report compatibility. return 0, 0, nil } - func (r *RecordingRepositoryImpl) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { var result float64 err := r.DB().WithContext(ctx). diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index a3756adf..1a63ad96 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1063,6 +1063,7 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) var standard productionStandardValues + var standardFcr *float64 if category == string(utils.ProjectFlockCategoryLaying) { detail, err := standardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -1082,6 +1083,21 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e } if growthDetail != nil { standard.FeedIntake = growthDetail.FeedIntake + if category == string(utils.ProjectFlockCategoryLaying) && growthDetail.TargetMeanBw != nil && item.ProjectFlockKandang.ProjectFlock.FcrId > 0 { + targetWeight := *growthDetail.TargetMeanBw + if targetWeight > 10 { + targetWeight = targetWeight / 1000 + } + if targetWeight > 0 { + fcrStd, ok, err := s.Repository.GetFcrStandardNumber(db, item.ProjectFlockKandang.ProjectFlock.FcrId, targetWeight) + if err != nil { + return err + } + if ok { + standardFcr = &fcrStd + } + } + } } item.StandardHandDay = standard.HandDay @@ -1089,6 +1105,7 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e item.StandardFeedIntake = standard.FeedIntake item.StandardEggMesh = standard.EggMesh item.StandardEggWeight = standard.EggWeight + item.StandardFcr = standardFcr return nil } From 0fc560b91ce2832a6b34b2b4f2b8ee71d81150ef Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 31 Dec 2025 09:40:05 +0700 Subject: [PATCH 13/29] fix(be): update nonstock query to use SupplierID as a non-pointer type --- .../nonstocks/controllers/nonstock.controller.go | 8 +++++--- .../master/nonstocks/services/nonstock.service.go | 12 ++++++------ .../nonstocks/validations/nonstock.validation.go | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/internal/modules/master/nonstocks/controllers/nonstock.controller.go b/internal/modules/master/nonstocks/controllers/nonstock.controller.go index d991c4da..2360bd09 100644 --- a/internal/modules/master/nonstocks/controllers/nonstock.controller.go +++ b/internal/modules/master/nonstocks/controllers/nonstock.controller.go @@ -23,10 +23,12 @@ func NewNonstockController(nonstockService service.NonstockService) *NonstockCon } func (u *NonstockController) GetAll(c *fiber.Ctx) error { + 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), + Search: c.Query("search", ""), + SupplierID: uint(c.QueryInt("supplier_id", 0)), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/nonstocks/services/nonstock.service.go b/internal/modules/master/nonstocks/services/nonstock.service.go index e201b1f1..876d4c1e 100644 --- a/internal/modules/master/nonstocks/services/nonstock.service.go +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -58,15 +58,15 @@ func (s nonstockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit offset := (params.Page - 1) * params.Limit nonstocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if params.SupplierID != nil { - supplierID := *params.SupplierID - db = db.Joins("JOIN nonstock_suppliers ON nonstock_suppliers.nonstock_id = nonstocks.id"). - Where("nonstock_suppliers.supplier_id = ?", supplierID). - Group("nonstocks.id") // Prevent duplicates from join + if params.SupplierID > 0 { + db = db.Joins("INNER JOIN nonstock_suppliers ON nonstock_suppliers.nonstock_id = nonstocks.id"). + Where("nonstock_suppliers.supplier_id = ?", params.SupplierID). + Distinct() } + db = s.withRelations(db) + if params.Search != "" { return db.Where("name LIKE ?", "%"+params.Search+"%") } diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index 6d39b205..62a41197 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -18,5 +18,5 @@ 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"` - SupplierID *uint `query:"supplier_id" validate:"omitempty,gt=0"` + SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` } From b8c0b0c37d9c5cf242786e3f30db5cb669054c19 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 09:44:20 +0700 Subject: [PATCH 14/29] feat(BE-278): add std for max_depletion --- internal/entities/recording.go | 13 +++++++------ .../production/recordings/dto/recording.dto.go | 2 ++ .../recordings/services/recording.service.go | 13 ++++++++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/internal/entities/recording.go b/internal/entities/recording.go index 03388ef2..7f952a62 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -34,10 +34,11 @@ type Recording struct { LatestApproval *Approval `gorm:"-" json:"-"` - StandardHandDay *float64 `gorm:"-"` - StandardHandHouse *float64 `gorm:"-"` - StandardFeedIntake *float64 `gorm:"-"` - StandardEggMesh *float64 `gorm:"-"` - StandardEggWeight *float64 `gorm:"-"` - StandardFcr *float64 `gorm:"-"` + StandardHandDay *float64 `gorm:"-"` + StandardHandHouse *float64 `gorm:"-"` + StandardFeedIntake *float64 `gorm:"-"` + StandardMaxDepletion *float64 `gorm:"-"` + StandardEggMesh *float64 `gorm:"-"` + StandardEggWeight *float64 `gorm:"-"` + StandardFcr *float64 `gorm:"-"` } diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 736eeaa7..c34651ba 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -33,6 +33,7 @@ type RecordingRelationDTO struct { StandardHandDay *float64 `json:"hand_day_std,omitempty"` StandardHandHouse *float64 `json:"hand_house_std,omitempty"` StandardFeedIntake *float64 `json:"feed_intake_std,omitempty"` + StandardMaxDepletion *float64 `json:"max_depletion_std,omitempty"` StandardEggMesh *float64 `json:"egg_mesh_std,omitempty"` StandardEggWeight *float64 `json:"egg_weight_std,omitempty"` StandardFcr *float64 `json:"fcr_std,omitempty"` @@ -164,6 +165,7 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { StandardHandDay: e.StandardHandDay, StandardHandHouse: e.StandardHandHouse, StandardFeedIntake: e.StandardFeedIntake, + StandardMaxDepletion: e.StandardMaxDepletion, StandardEggMesh: e.StandardEggMesh, StandardEggWeight: e.StandardEggWeight, StandardFcr: e.StandardFcr, diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 1a63ad96..5b09d003 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1016,11 +1016,12 @@ func (s *recordingService) attachLatestApproval(ctx context.Context, item *entit } type productionStandardValues struct { - HandDay *float64 - HandHouse *float64 - FeedIntake *float64 - EggMesh *float64 - EggWeight *float64 + HandDay *float64 + HandHouse *float64 + FeedIntake *float64 + MaxDepletion *float64 + EggMesh *float64 + EggWeight *float64 } func (s *recordingService) attachProductionStandards(ctx context.Context, items []entity.Recording) error { @@ -1083,6 +1084,7 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e } if growthDetail != nil { standard.FeedIntake = growthDetail.FeedIntake + standard.MaxDepletion = growthDetail.MaxDepletion if category == string(utils.ProjectFlockCategoryLaying) && growthDetail.TargetMeanBw != nil && item.ProjectFlockKandang.ProjectFlock.FcrId > 0 { targetWeight := *growthDetail.TargetMeanBw if targetWeight > 10 { @@ -1103,6 +1105,7 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e item.StandardHandDay = standard.HandDay item.StandardHandHouse = standard.HandHouse item.StandardFeedIntake = standard.FeedIntake + item.StandardMaxDepletion = standard.MaxDepletion item.StandardEggMesh = standard.EggMesh item.StandardEggWeight = standard.EggWeight item.StandardFcr = standardFcr From 9d285869f55df0a3e9d4a84cbcedc320493e40fb Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 11:39:53 +0700 Subject: [PATCH 15/29] feat(BE): add function read and download in document --- .../common/service/common.document.service.go | 12 ++++++ .../common/service/common.document.storage.go | 37 ++++++++++++++++--- .../controllers/uniformity.controller.go | 9 +++-- .../uniformities/dto/uniformity.dto.go | 6 +++ .../services/uniformity.service.go | 29 ++++++++------- 5 files changed, 70 insertions(+), 23 deletions(-) diff --git a/internal/common/service/common.document.service.go b/internal/common/service/common.document.service.go index fe2a41cc..079e3eba 100644 --- a/internal/common/service/common.document.service.go +++ b/internal/common/service/common.document.service.go @@ -8,6 +8,7 @@ import ( "mime/multipart" "path/filepath" "strings" + "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/config" @@ -29,6 +30,7 @@ type DocumentService interface { DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error PublicURL(document entity.Document) string + PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error) } type DocumentUploadRequest struct { @@ -293,6 +295,16 @@ func (s *documentService) PublicURL(document entity.Document) string { return s.storage.URL(document.Path) } +func (s *documentService) PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error) { + if s.storage == nil { + return "", errors.New("document storage not configured") + } + if strings.TrimSpace(document.Path) == "" { + return "", errors.New("document path is required") + } + return s.storage.PresignURL(ctx, document.Path, expires) +} + func (s *documentService) generateObjectKey(ext string) (string, error) { normalizedExt := strings.TrimSpace(ext) if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") { diff --git a/internal/common/service/common.document.storage.go b/internal/common/service/common.document.storage.go index 24e6fade..42909dbd 100644 --- a/internal/common/service/common.document.storage.go +++ b/internal/common/service/common.document.storage.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -17,6 +18,7 @@ type DocumentStorage interface { Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error) Delete(ctx context.Context, key string) error URL(key string) string + PresignURL(ctx context.Context, key string, expires time.Duration) (string, error) } type DocumentStorageUploadResult struct { @@ -36,9 +38,10 @@ type S3DocumentStorageConfig struct { } type s3DocumentStorage struct { - client *s3.Client - bucket string - base string + client *s3.Client + presignClient *s3.PresignClient + bucket string + base string } func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (DocumentStorage, error) { @@ -86,6 +89,7 @@ func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (Doc client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { o.UsePathStyle = cfg.ForcePathStyle }) + presignClient := s3.NewPresignClient(client) baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/") if baseURL == "" { @@ -97,9 +101,10 @@ func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (Doc } return &s3DocumentStorage{ - client: client, - bucket: bucket, - base: baseURL, + client: client, + presignClient: presignClient, + bucket: bucket, + base: baseURL, }, nil } @@ -158,3 +163,23 @@ func (s *s3DocumentStorage) URL(key string) string { } return fmt.Sprintf("%s/%s", s.base, key) } + +func (s *s3DocumentStorage) PresignURL(ctx context.Context, key string, expires time.Duration) (string, error) { + key = strings.TrimPrefix(strings.TrimSpace(key), "/") + if key == "" { + return "", errors.New("storage key is required") + } + if expires <= 0 { + expires = 15 * time.Minute + } + + out, err := s.presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(expires)) + if err != nil { + return "", err + } + + return out.URL, nil +} diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go index 12cc3739..4edf357b 100644 --- a/internal/modules/production/uniformities/controllers/uniformity.controller.go +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -71,13 +71,14 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { withDetails := c.QueryBool("with_details", false) calculation := service.UniformityCalculation{} var document *entity.Document + var documentURL string var meanWeight float64 if result.MeanUp > 0 { meanWeight = math.Round(result.MeanUp / 1.10) } if withDetails { var err error - calculation, document, err = u.UniformityService.CalculateUniformityFromDocument(c, id) + calculation, document, documentURL, err = u.UniformityService.CalculateUniformityFromDocument(c, id) if err != nil { return err } @@ -111,7 +112,7 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get production uniformity successfully", - Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO), + Data: dto.ToUniformityDetailDTO(*result, calculation, document, documentURL, standardDTO), }) } @@ -154,7 +155,7 @@ func (u *UniformityController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create uniformity successfully", - Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO), + Data: dto.ToUniformityDetailDTO(*result, calculation, document, "", standardDTO), }) } @@ -237,7 +238,7 @@ func (u *UniformityController) UpdateOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Update uniformity successfully", - Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO), + Data: dto.ToUniformityDetailDTO(*result, calculation, document, "", standardDTO), }) } diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go index 1324d805..4a813b98 100644 --- a/internal/modules/production/uniformities/dto/uniformity.dto.go +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -45,6 +45,7 @@ type UniformityInfoDTO struct { ProjectFlock string `json:"project_flock"` Kandang string `json:"kandang"` FileName string `json:"file_name"` + FileURL string `json:"file_url"` } type UniformityDetailDTO struct { @@ -97,6 +98,7 @@ func ToUniformityDetailDTO( entityData entity.ProjectFlockKandangUniformity, calc service.UniformityCalculation, document *entity.Document, + documentURL string, standard *UniformityStandardDTO, ) UniformityDetailDTO { info := UniformityInfoDTO{ @@ -105,10 +107,14 @@ func ToUniformityDetailDTO( ProjectFlock: resolveProjectFlockName(entityData.ProjectFlockKandang), Kandang: resolveKandangName(entityData.ProjectFlockKandang), FileName: "", + FileURL: "", } if document != nil { info.FileName = document.Name } + if documentURL != "" { + info.FileURL = documentURL + } return UniformityDetailDTO{ Id: entityData.Id, diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 2e76e48f..c999867d 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -39,7 +39,7 @@ type UniformityService interface { Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) - CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, error) + CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) } type uniformityService struct { @@ -592,50 +592,53 @@ func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (Uniform return computeUniformity(rows) } -func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, error) { +func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) { if s.DocumentSvc == nil { - return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusInternalServerError, "Document service not available") } documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(uniformityID)) if err != nil { - return UniformityCalculation{}, nil, err + return UniformityCalculation{}, nil, "", err } if len(documents) == 0 { - return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") + return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") } document := documents[0] - url := s.DocumentSvc.PublicURL(document) + url, err := s.DocumentSvc.PresignURL(c.Context(), document, 15*time.Minute) + if err != nil { + return UniformityCalculation{}, nil, "", err + } if url == "" { - return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available") + return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available") } req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil) if err != nil { - return UniformityCalculation{}, nil, err + return UniformityCalculation{}, nil, "", err } resp, err := http.DefaultClient.Do(req) if err != nil { - return UniformityCalculation{}, nil, err + return UniformityCalculation{}, nil, "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") + return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") } rows, err := parseBodyWeightExcelReader(resp.Body) if err != nil { - return UniformityCalculation{}, nil, err + return UniformityCalculation{}, nil, "", err } calculation, err := computeUniformity(rows) if err != nil { - return UniformityCalculation{}, nil, err + return UniformityCalculation{}, nil, "", err } - return calculation, &document, nil + return calculation, &document, url, nil } func (s *uniformityService) createUniformityApproval( From 47d497d6b0eb778123e353f4b87898713dad7ee9 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Wed, 31 Dec 2025 13:15:02 +0700 Subject: [PATCH 16/29] fix rename route api closing data production --- internal/modules/closings/route.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 52333b67..79c83c22 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -30,6 +30,6 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak) route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPP) route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang) - route.Get("/:projectFlockId/data-produksi", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) + route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan) } From e0dd2799fc857a8f5815ddb03772d8f962076b44 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 15:10:06 +0700 Subject: [PATCH 17/29] feat(BE): fix fifo system recording and uniformity dto --- .../repository/common.base.repository.go | 3 +- .../common/service/common.fifo.service.go | 2 +- .../recordings/services/recording.service.go | 34 ++++++++++-- .../controllers/uniformity.controller.go | 4 ++ .../services/uniformity.service.go | 52 +++++++++++++------ 5 files changed, 73 insertions(+), 22 deletions(-) diff --git a/internal/common/repository/common.base.repository.go b/internal/common/repository/common.base.repository.go index fa58fcd7..27eea03a 100644 --- a/internal/common/repository/common.base.repository.go +++ b/internal/common/repository/common.base.repository.go @@ -187,10 +187,11 @@ func (r *BaseRepositoryImpl[T]) PatchOne( updates map[string]any, modifier func(*gorm.DB) *gorm.DB, ) error { - q := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id) + q := r.db.WithContext(ctx) if modifier != nil { q = modifier(q) } + q = q.Model(new(T)).Where("id = ?", id) result := q.Updates(updates) if result.Error != nil { diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index bf97f831..35aa2a5a 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -715,7 +715,7 @@ func (s *fifoService) releaseUsagePortion( } } else { if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{ - "quantity": allocation.Qty - releaseAmt, + "qty": allocation.Qty - releaseAmt, }, func(db *gorm.DB) *gorm.DB { return s.txOrDB(tx, db) }); err != nil { diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5b09d003..946aa5b3 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -229,7 +229,13 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent CreatedBy: actorID, } - if err := s.Repository.CreateOne(ctx, &createdRecording, func(*gorm.DB) *gorm.DB { return tx }); err != nil { + createTx := tx.WithContext(ctx).Select( + "ProjectFlockKandangId", + "RecordDatetime", + "Day", + "CreatedBy", + ) + if err := createTx.Create(&createdRecording).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return fiber.NewError( fiber.StatusBadRequest, @@ -299,9 +305,11 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin ctx := c.Context() var recordingEntity *entity.Recording + var updatedRecording *entity.Recording transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - recording, err := s.Repository.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { - return s.Repository.WithRelations(tx) + repoTx := s.Repository.WithTx(tx) + recording, err := repoTx.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(db) }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -470,13 +478,31 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } + updated, err := repoTx.GetByID(ctx, recordingEntity.Id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(db) + }) + if err != nil { + s.Log.Errorf("Failed to reload recording %d after update: %+v", recordingEntity.Id, err) + return err + } + updatedRecording = updated + return nil }) if transactionErr != nil { return nil, transactionErr } - return s.GetOne(c, id) + if updatedRecording == nil { + return s.GetOne(c, id) + } + if err := s.attachLatestApproval(ctx, updatedRecording); err != nil { + return nil, err + } + if err := s.attachProductionStandard(ctx, updatedRecording); err != nil { + return nil, err + } + return updatedRecording, nil } func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) { diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go index 4edf357b..ce91c3af 100644 --- a/internal/modules/production/uniformities/controllers/uniformity.controller.go +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -93,6 +93,10 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { Uniformity: result.Uniformity, Cv: result.Cv, } + document, documentURL, err = u.UniformityService.GetDocumentInfo(c, id) + if err != nil { + return err + } } standard, err := u.UniformityService.GetStandard(c, result) diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index c999867d..318fabc0 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -39,6 +39,7 @@ type UniformityService interface { Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) + GetDocumentInfo(ctx *fiber.Ctx, uniformityID uint) (*entity.Document, string, error) CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) } @@ -592,28 +593,19 @@ func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (Uniform return computeUniformity(rows) } +func (s uniformityService) GetDocumentInfo(c *fiber.Ctx, uniformityID uint) (*entity.Document, string, error) { + return s.fetchUniformityDocument(c.Context(), uniformityID, true) +} + func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) { - if s.DocumentSvc == nil { - return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusInternalServerError, "Document service not available") - } - - documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(uniformityID)) + document, url, err := s.fetchUniformityDocument(c.Context(), uniformityID, false) if err != nil { return UniformityCalculation{}, nil, "", err } - if len(documents) == 0 { + if document == nil || url == "" { return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") } - document := documents[0] - url, err := s.DocumentSvc.PresignURL(c.Context(), document, 15*time.Minute) - if err != nil { - return UniformityCalculation{}, nil, "", err - } - if url == "" { - return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available") - } - req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil) if err != nil { return UniformityCalculation{}, nil, "", err @@ -638,7 +630,35 @@ func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniform return UniformityCalculation{}, nil, "", err } - return calculation, &document, url, nil + return calculation, document, url, nil +} + +func (s uniformityService) fetchUniformityDocument(ctx context.Context, uniformityID uint, allowMissing bool) (*entity.Document, string, error) { + if s.DocumentSvc == nil { + return nil, "", fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } + + documents, err := s.DocumentSvc.ListByTarget(ctx, "UNIFORMITY", uint64(uniformityID)) + if err != nil { + return nil, "", err + } + if len(documents) == 0 { + if allowMissing { + return nil, "", nil + } + return nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") + } + + document := documents[0] + url, err := s.DocumentSvc.PresignURL(ctx, document, 15*time.Minute) + if err != nil { + return nil, "", err + } + if url == "" { + return nil, "", fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available") + } + + return &document, url, nil } func (s *uniformityService) createUniformityApproval( From fe51f33ab4ad5e1110042fe875ca7974d981b854 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 19:30:04 +0700 Subject: [PATCH 18/29] feat(BE): fixing fifo system recording --- .../common/service/common.fifo.service.go | 63 +++ .../services/adjustment.service.go | 26 +- .../repositories/recording.repository.go | 13 +- .../recordings/services/recording.service.go | 409 ++++++++++++++++-- 4 files changed, 451 insertions(+), 60 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 35aa2a5a..5b7adc2e 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -192,6 +192,16 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St if req.Quantity < 0 { return nil, errors.New("quantity must be zero or greater") } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "requested_quantity": req.Quantity, + "allow_pending": req.AllowPending, + "product_warehouse_id": req.ProductWarehouseID, + }).Debug("fifo consume request") + } + cfg, ok := fifo.Usable(req.UsableKey) if !ok { @@ -220,6 +230,19 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St currentPending := ctxRow.PendingQty currentTotal := currentUsage + currentPending delta := req.Quantity - currentTotal + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "product_warehouse_id": productWarehouseID, + "current_usage_qty": currentUsage, + "current_pending_qty": currentPending, + "current_total_qty": currentTotal, + "requested_quantity": req.Quantity, + "calculated_delta": delta, + "input_warehouse_match": req.ProductWarehouseID == 0 || req.ProductWarehouseID == productWarehouseID, + }).Debug("fifo consume context") + } var ( usageDelta float64 @@ -285,6 +308,20 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St result.ReleasedQuantity = releasedAmount result.UsageQuantity = currentUsage + usageDelta result.PendingQuantity = currentPending + pendingDelta + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "product_warehouse_id": productWarehouseID, + "usage_delta": usageDelta, + "pending_delta": pendingDelta, + "released_quantity": releasedAmount, + "added_allocations": len(addedAlloc), + "final_usage_qty": result.UsageQuantity, + "final_pending_qty": result.PendingQuantity, + "final_requested_qty": result.RequestedQuantity, + }).Debug("fifo consume result") + } return nil }) @@ -299,6 +336,13 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { return errors.New("usable key and id are required") } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "reason": req.Reason, + }).Debug("fifo release request") + } return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { cfg, ok := fifo.Usable(req.UsableKey) @@ -310,6 +354,16 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) if err != nil { return err } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "product_warehouse_id": ctxRow.ProductWarehouseID, + "current_usage_qty": ctxRow.UsageQty, + "current_pending_qty": ctxRow.PendingQty, + "current_total_qty": ctxRow.UsageQty + ctxRow.PendingQty, + }).Debug("fifo release context") + } var usageDelta, pendingDelta float64 if ctxRow.UsageQty > 0 { @@ -326,6 +380,15 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) return err } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "usage_delta": usageDelta, + "pending_delta": pendingDelta, + }).Debug("fifo release applied") + } + return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB { return s.txOrDB(tx, db) }) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index f15f37df..47d41648 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -194,13 +194,17 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e StockLogId: newLog.Id, ProductWarehouseId: productWarehouse.Id, } + if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { + s.Log.Errorf("Failed to create adjustment stock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") + } if transactionType == string(utils.StockLogTransactionTypeIncrease) { // Adjustment INCREASE → Replenish stock (Stockable) note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) - replenishResult, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ + _, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ StockableKey: "ADJUSTMENT_IN", - StockableID: newLog.Id, + StockableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, Note: ¬e, @@ -210,15 +214,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err)) } - // Update stockable tracking fields - adjustmentStock.TotalQty = replenishResult.AddedQuantity - adjustmentStock.TotalUsed = 0 - } else { // Adjustment DECREASE → Consume stock (Usable) - consumeResult, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ + _, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ UsableKey: "ADJUSTMENT_OUT", - UsableID: newLog.Id, + UsableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, AllowPending: false, // Don't allow pending for adjustment @@ -227,16 +227,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err)) } - - // Update usable tracking fields - adjustmentStock.UsageQty = consumeResult.UsageQuantity - adjustmentStock.PendingQty = consumeResult.PendingQuantity - } - - // Save AdjustmentStock record - if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { - s.Log.Errorf("Failed to create adjustment stock: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") } // Update ProductWarehouse quantity (for backward compatibility/reporting) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index d75060ad..941d4507 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -295,16 +295,17 @@ func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm. func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { var rows []struct { - UsageQty float64 + TotalQty float64 UomName string } if err := tx. Table("recording_stocks"). - Select("COALESCE(recording_stocks.usage_qty, 0) AS usage_qty, LOWER(uoms.name) AS uom_name"). + Select("COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0) AS total_qty, 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"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ? AND UPPER(flags.name) = ?", entity.FlagableTypeProduct, "PAKAN"). Where("recording_stocks.recording_id = ?", recordingID). Scan(&rows).Error; err != nil { return 0, err @@ -312,16 +313,16 @@ func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID u var total float64 for _, row := range rows { - if row.UsageQty <= 0 { + if row.TotalQty <= 0 { continue } switch strings.TrimSpace(row.UomName) { case "kilogram", "kg", "kilograms", "kilo": - total += row.UsageQty * 1000 + total += row.TotalQty * 1000 case "gram", "g", "grams": - total += row.UsageQty + total += row.TotalQty default: - total += row.UsageQty + total += row.TotalQty } } return total, nil diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 946aa5b3..c9ca74f5 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -247,11 +247,13 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) + stockDesired := resetStockQuantitiesForFIFO(mappedStocks, s.FifoSvc != nil) if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { s.Log.Errorf("Failed to persist stocks: %+v", err) return err } + applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil) if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { return err } @@ -324,6 +326,49 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin hasDepletionChanges := req.Depletions != nil hasEggChanges := req.Eggs != nil + var existingStocks []entity.RecordingStock + if hasStockChanges { + existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing stocks: %+v", err) + return err + } + if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { + s.Log.WithFields(logrus.Fields{ + "recording_id": recordingEntity.Id, + "existing": summarizeExistingStocks(existingStocks), + "incoming": summarizeIncomingStocks(req.Stocks), + }).Debug("recording update stock comparison") + } + if stocksMatch(existingStocks, req.Stocks) { + hasStockChanges = false + } + } + + var existingDepletions []entity.RecordingDepletion + if hasDepletionChanges { + existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing depletions: %+v", err) + return err + } + if depletionsMatch(existingDepletions, req.Depletions) { + hasDepletionChanges = false + } + } + + var existingEggs []entity.RecordingEgg + if hasEggChanges { + existingEggs, err = s.Repository.ListEggs(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing eggs: %+v", err) + return err + } + if eggsMatch(existingEggs, req.Eggs) { + hasEggChanges = false + } + } + if !hasStockChanges && !hasDepletionChanges && !hasEggChanges { return nil } @@ -355,39 +400,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if hasStockChanges { - existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing stocks: %+v", err) - return err - } - - if err := s.releaseRecordingStocks(ctx, tx, existingStocks); err != nil { - return err - } - - if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil { - s.Log.Errorf("Failed to clear stocks: %+v", err) - return err - } - - mappedStocks := recordingutil.MapStocks(recordingEntity.Id, req.Stocks) - if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { - s.Log.Errorf("Failed to update stocks: %+v", err) - return err - } - - if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { + if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks); err != nil { return err } } if hasDepletionChanges { - existingDepletions, err := s.Repository.ListDepletions(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing depletions: %+v", err) - return err - } - if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear depletions: %+v", err) return err @@ -406,12 +424,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if hasEggChanges { - existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing eggs: %+v", err) - return err - } - if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear eggs: %+v", err) return err @@ -429,7 +441,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if hasStockChanges || hasDepletionChanges { + if hasStockChanges || hasDepletionChanges || hasEggChanges { if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { s.Log.Errorf("Failed to recompute recording metrics: %+v", err) return err @@ -680,12 +692,27 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. if stock.UsageQty != nil { desired = *stock.UsageQty } + var pending float64 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + desiredTotal := desired + pending + + if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { + s.Log.WithFields(logrus.Fields{ + "recording_stock_id": stock.Id, + "product_warehouse_id": stock.ProductWarehouseId, + "desired_usage_qty": desired, + "desired_pending_qty": pending, + "desired_total_qty": desiredTotal, + }).Debug("recording fifo consume start") + } result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: recordingStockUsableKey, UsableID: stock.Id, ProductWarehouseID: stock.ProductWarehouseId, - Quantity: desired, + Quantity: desiredTotal, AllowPending: true, Tx: tx, }) @@ -694,6 +721,17 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. return err } + if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { + s.Log.WithFields(logrus.Fields{ + "recording_stock_id": stock.Id, + "product_warehouse_id": stock.ProductWarehouseId, + "result_usage_qty": result.UsageQuantity, + "result_pending_qty": result.PendingQuantity, + "released_qty": result.ReleasedQuantity, + "added_allocations": len(result.AddedAllocations), + }).Debug("recording fifo consume result") + } + if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } @@ -716,6 +754,23 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm. continue } + var usage float64 + var pending float64 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { + s.Log.WithFields(logrus.Fields{ + "recording_stock_id": stock.Id, + "product_warehouse_id": stock.ProductWarehouseId, + "current_usage_qty": usage, + "current_pending_qty": pending, + }).Debug("recording fifo release start") + } + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ UsableKey: recordingStockUsableKey, UsableID: stock.Id, @@ -771,6 +826,288 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) } +type desiredStock struct { + Usage float64 + Pending float64 +} + +func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { + desired := make([]desiredStock, len(stocks)) + for i := range stocks { + if stocks[i].UsageQty != nil { + desired[i].Usage = *stocks[i].UsageQty + } + if stocks[i].PendingQty != nil { + desired[i].Pending = *stocks[i].PendingQty + } + if !enabled { + continue + } + zero := 0.0 + stocks[i].UsageQty = &zero + stocks[i].PendingQty = &zero + } + return desired +} + +func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) { + if !enabled { + return + } + for i := range stocks { + if i >= len(desired) { + break + } + usage := desired[i].Usage + pending := desired[i].Pending + stocks[i].UsageQty = &usage + stocks[i].PendingQty = &pending + } +} + +func (s *recordingService) syncRecordingStocks( + ctx context.Context, + tx *gorm.DB, + recordingID uint, + existing []entity.RecordingStock, + incoming []validation.Stock, +) error { + if s.FifoSvc == nil { + if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { + return err + } + mapped := recordingutil.MapStocks(recordingID, incoming) + return s.Repository.CreateStocks(tx, mapped) + } + + existingByWarehouse := make(map[uint][]entity.RecordingStock) + for _, stock := range existing { + existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) + } + + stocksToConsume := make([]entity.RecordingStock, 0, len(incoming)) + for _, item := range incoming { + list := existingByWarehouse[item.ProductWarehouseId] + var stock entity.RecordingStock + if len(list) > 0 { + stock = list[0] + existingByWarehouse[item.ProductWarehouseId] = list[1:] + } else { + zero := 0.0 + stock = entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + UsageQty: &zero, + PendingQty: &zero, + } + if err := tx.Create(&stock).Error; err != nil { + return err + } + } + + desired := item.Qty + stock.UsageQty = &desired + if item.PendingQty != nil { + pending := *item.PendingQty + stock.PendingQty = &pending + } + stocksToConsume = append(stocksToConsume, stock) + } + + var leftovers []entity.RecordingStock + for _, list := range existingByWarehouse { + leftovers = append(leftovers, list...) + } + if len(leftovers) > 0 { + if err := s.releaseRecordingStocks(ctx, tx, leftovers); err != nil { + return err + } + ids := make([]uint, 0, len(leftovers)) + for _, stock := range leftovers { + if stock.Id != 0 { + ids = append(ids, stock.Id) + } + } + if len(ids) > 0 { + if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil { + return err + } + } + } + + if len(stocksToConsume) == 0 { + return nil + } + return s.consumeRecordingStocks(ctx, tx, stocksToConsume) +} + +type eggTotals struct { + Qty int + Weight float64 +} + +type stockTotals struct { + Usage float64 + Pending float64 + Total float64 +} + +func summarizeExistingStocks(stocks []entity.RecordingStock) map[uint]stockTotals { + totals := make(map[uint]stockTotals) + for _, stock := range stocks { + var usage float64 + var pending float64 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + current := totals[stock.ProductWarehouseId] + current.Usage += usage + current.Pending += pending + current.Total += usage + pending + totals[stock.ProductWarehouseId] = current + } + return totals +} + +func summarizeIncomingStocks(stocks []validation.Stock) map[uint]stockTotals { + totals := make(map[uint]stockTotals) + for _, stock := range stocks { + var pending float64 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + current := totals[stock.ProductWarehouseId] + current.Usage += stock.Qty + current.Pending += pending + current.Total += stock.Qty + pending + totals[stock.ProductWarehouseId] = current + } + return totals +} + +func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { + hasPending := false + for _, item := range incoming { + if item.PendingQty != nil { + hasPending = true + break + } + } + + existingUsage := make(map[uint]float64) + existingTotal := make(map[uint]float64) + for _, stock := range existing { + var usage float64 + var pending float64 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + existingUsage[stock.ProductWarehouseId] += usage + existingTotal[stock.ProductWarehouseId] += usage + pending + } + + incomingUsage := make(map[uint]float64) + incomingTotal := make(map[uint]float64) + for _, item := range incoming { + var pending float64 + if item.PendingQty != nil { + pending = *item.PendingQty + } + incomingUsage[item.ProductWarehouseId] += item.Qty + incomingTotal[item.ProductWarehouseId] += item.Qty + pending + } + + if hasPending { + return floatMapsMatch(existingTotal, incomingTotal) + } + return floatMapsMatch(existingUsage, incomingUsage) +} + +func depletionsMatch(existing []entity.RecordingDepletion, incoming []validation.Depletion) bool { + existingTotals := make(map[uint]float64) + for _, dep := range existing { + existingTotals[dep.ProductWarehouseId] += dep.Qty + } + + incomingTotals := make(map[uint]float64) + for _, dep := range incoming { + incomingTotals[dep.ProductWarehouseId] += dep.Qty + } + + return floatMapsMatch(existingTotals, incomingTotals) +} + +func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { + existingTotals := make(map[uint]eggTotals) + for _, egg := range existing { + weight := 0.0 + if egg.Weight != nil { + weight = *egg.Weight + } + current := existingTotals[egg.ProductWarehouseId] + current.Qty += egg.Qty + current.Weight += float64(egg.Qty) * weight + existingTotals[egg.ProductWarehouseId] = current + } + + incomingTotals := make(map[uint]eggTotals) + for _, egg := range incoming { + weight := 0.0 + if egg.Weight != nil { + weight = *egg.Weight + } + current := incomingTotals[egg.ProductWarehouseId] + current.Qty += egg.Qty + current.Weight += float64(egg.Qty) * weight + incomingTotals[egg.ProductWarehouseId] = current + } + + if len(existingTotals) != len(incomingTotals) { + return false + } + + for key, existingTotal := range existingTotals { + incomingTotal, ok := incomingTotals[key] + if !ok { + return false + } + if existingTotal.Qty != incomingTotal.Qty { + return false + } + if !floatNearlyEqual(existingTotal.Weight, incomingTotal.Weight) { + return false + } + } + + return true +} + +func floatMapsMatch(a, b map[uint]float64) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + other, ok := b[key] + if !ok { + return false + } + if !floatNearlyEqual(value, other) { + return false + } + } + return true +} + +func floatNearlyEqual(a, b float64) bool { + return math.Abs(a-b) <= 0.000001 +} + func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error { day := 0 if recording.Day != nil { From cc5a58b6d1af7a9fbd8cecfb19ce2819031e0037 Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 2 Jan 2026 12:04:50 +0700 Subject: [PATCH 19/29] feat(BE): sso delete and fix response too many request --- ...02045853_fix_soft_delete_fk_casts.down.sql | 126 ++++++++++++++++ ...0102045853_fix_soft_delete_fk_casts.up.sql | 142 ++++++++++++++++++ .../modules/sso/controllers/sso.controller.go | 3 + .../users/repositories/user.repository.go | 4 +- 4 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql create mode 100644 internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql diff --git a/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql new file mode 100644 index 00000000..161d3d89 --- /dev/null +++ b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql @@ -0,0 +1,126 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)', + fk.child_table, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql new file mode 100644 index 00000000..2801ac2e --- /dev/null +++ b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql @@ -0,0 +1,142 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; + child_type text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + SELECT format_type(atttypid, atttypmod) INTO child_type + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = child_column + AND NOT attisdropped; + + IF child_type IS NULL THEN + child_type := 'text'; + END IF; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1::%s %s)', + fk.child_table, + child_column, + child_type, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1::%s %s', + fk.child_table, + child_column, + child_column, + child_type, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1::%s AND deleted_at IS NULL', + fk.child_table, + child_column, + child_type + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1::%s', + fk.child_table, + child_column, + child_type + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1::%s %s', + fk.child_table, + child_column, + child_column, + child_type, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index 99bd67d6..554b3388 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -171,6 +171,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error { if resp.StatusCode >= 400 { utils.Log.Warnf("token refresh response status %d", resp.StatusCode) + if resp.StatusCode == fiber.StatusTooManyRequests { + return fiber.NewError(fiber.StatusTooManyRequests, "Too many attempts, please slow down") + } return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") } diff --git a/internal/modules/users/repositories/user.repository.go b/internal/modules/users/repositories/user.repository.go index f9bee9ed..b3cac2dc 100644 --- a/internal/modules/users/repositories/user.repository.go +++ b/internal/modules/users/repositories/user.repository.go @@ -42,7 +42,7 @@ func (r *UserRepositoryImpl) GetByIdUser( modifier func(*gorm.DB) *gorm.DB, ) (*entity.User, error) { return r.BaseRepositoryImpl.First(ctx, func(db *gorm.DB) *gorm.DB { - return db.Where("id_user = ?", idUser) + return db.Where("id_user::bigint = ?::bigint", idUser) }) } @@ -93,7 +93,7 @@ func (r *UserRepositoryImpl) UpsertByIdUser(ctx context.Context, user *entity.Us } func (r *UserRepositoryImpl) SoftDeleteByIdUser(ctx context.Context, idUser int64) error { - query := r.DB().WithContext(ctx).Where("id_user = ?", idUser) + query := r.DB().WithContext(ctx).Where("id_user::bigint = ?::bigint", idUser) result := query.Delete(&entity.User{}) if result.Error != nil { return result.Error From 1348483b1c904e8ed732c6de0f628ff0ebef7f58 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Fri, 2 Jan 2026 13:19:11 +0700 Subject: [PATCH 20/29] adjust api closing tap sapronak --- .../closings/repositories/closing.repository.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 912f2f25..4948ae5e 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -407,7 +407,7 @@ SELECT COALESCE(fw.name, '') AS source_warehouse, COALESCE(tw.name, '') AS destination_warehouse, '' AS destination, - std.quantity AS quantity, + std.usage_qty AS quantity, u.name AS unit, 'Stock Refill' AS notes FROM stock_transfer_details std @@ -456,7 +456,7 @@ SELECT COALESCE(fw.name, '') AS source_warehouse, '' AS destination_warehouse, COALESCE(tw.name, '') AS destination, - std.quantity AS quantity, + std.usage_qty AS quantity, u.name AS unit, 'Transfer to other unit' AS notes FROM stock_transfer_details std @@ -927,34 +927,34 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C COALESCE(SUM( CASE WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) ELSE 0 END ), 0) AS total_qty, COALESCE(SUM( CASE WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0) ELSE 0 END ), 0) AS total_price, COALESCE(SUM( CASE WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) ELSE 0 END ), 0) AS qty_divisor, COALESCE(SUM( CASE WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0) ELSE 0 END ), 0) / NULLIF(COALESCE(SUM( CASE WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) ELSE 0 END ), 0), 0) AS average_price`, From 8de33a0f24e86331955aba70d4377853262c20de Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 2 Jan 2026 20:43:57 +0700 Subject: [PATCH 21/29] feat(BE): fix delete project flock budget and uniformity, and fix uniformity with update purchase document --- .../common/service/common.document.service.go | 51 +++++++++++ .../common/service/common.fifo.service.go | 68 -------------- ...uniformity_project_budget_cascade.down.sql | 86 ++++++++++++++++++ ...e_uniformity_project_budget_cascade.up.sql | 90 +++++++++++++++++++ .../project_flock_kandang_uniformity.go | 9 +- .../services/projectflock.service.go | 9 ++ .../recordings/services/recording.service.go | 45 ---------- .../uniformities/dto/uniformity.dto.go | 2 - .../repositories/uniformity.repository.go | 13 +++ .../services/uniformity.service.go | 2 +- .../controllers/purchase.controller.go | 5 +- .../purchases/services/purchase.service.go | 64 ++++++++++++- internal/utils/constant.go | 4 +- 13 files changed, 320 insertions(+), 128 deletions(-) create mode 100644 internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql create mode 100644 internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql diff --git a/internal/common/service/common.document.service.go b/internal/common/service/common.document.service.go index 079e3eba..44f2c116 100644 --- a/internal/common/service/common.document.service.go +++ b/internal/common/service/common.document.service.go @@ -6,6 +6,7 @@ import ( "fmt" "mime" "mime/multipart" + "net/url" "path/filepath" "strings" "time" @@ -305,6 +306,56 @@ func (s *documentService) PresignURL(ctx context.Context, document entity.Docume return s.storage.PresignURL(ctx, document.Path, expires) } +// ResolveDocumentURL normalizes a stored path or URL into a presigned URL. +func ResolveDocumentURL( + ctx context.Context, + svc DocumentService, + rawPath string, + expires time.Duration, +) (string, error) { + if svc == nil { + return "", nil + } + + rawPath = strings.TrimSpace(rawPath) + if rawPath == "" { + return "", nil + } + + key := rawPath + lower := strings.ToLower(rawPath) + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + key = extractS3KeyFromURL(rawPath) + if key == "" { + return "", nil + } + } + + return svc.PresignURL(ctx, entity.Document{Path: key}, expires) +} + +func extractS3KeyFromURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "" + } + path := strings.TrimPrefix(parsed.Path, "/") + if path == "" { + return "" + } + + host := strings.ToLower(strings.TrimSpace(parsed.Host)) + if strings.HasPrefix(host, "s3.") || strings.HasPrefix(host, "s3-") { + parts := strings.SplitN(path, "/", 2) + if len(parts) == 2 { + return parts[1] + } + return "" + } + + return path +} + func (s *documentService) generateObjectKey(ext string) (string, error) { normalizedExt := strings.TrimSpace(ext) if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") { diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 5b7adc2e..2a65c1b4 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -192,17 +192,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St if req.Quantity < 0 { return nil, errors.New("quantity must be zero or greater") } - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "requested_quantity": req.Quantity, - "allow_pending": req.AllowPending, - "product_warehouse_id": req.ProductWarehouseID, - }).Debug("fifo consume request") - } - - cfg, ok := fifo.Usable(req.UsableKey) if !ok { return nil, fmt.Errorf("usable %q is not registered", req.UsableKey) @@ -230,20 +219,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St currentPending := ctxRow.PendingQty currentTotal := currentUsage + currentPending delta := req.Quantity - currentTotal - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "product_warehouse_id": productWarehouseID, - "current_usage_qty": currentUsage, - "current_pending_qty": currentPending, - "current_total_qty": currentTotal, - "requested_quantity": req.Quantity, - "calculated_delta": delta, - "input_warehouse_match": req.ProductWarehouseID == 0 || req.ProductWarehouseID == productWarehouseID, - }).Debug("fifo consume context") - } - var ( usageDelta float64 pendingDelta float64 @@ -308,21 +283,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St result.ReleasedQuantity = releasedAmount result.UsageQuantity = currentUsage + usageDelta result.PendingQuantity = currentPending + pendingDelta - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "product_warehouse_id": productWarehouseID, - "usage_delta": usageDelta, - "pending_delta": pendingDelta, - "released_quantity": releasedAmount, - "added_allocations": len(addedAlloc), - "final_usage_qty": result.UsageQuantity, - "final_pending_qty": result.PendingQuantity, - "final_requested_qty": result.RequestedQuantity, - }).Debug("fifo consume result") - } - return nil }) if err != nil { @@ -336,14 +296,6 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { return errors.New("usable key and id are required") } - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "reason": req.Reason, - }).Debug("fifo release request") - } - return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { cfg, ok := fifo.Usable(req.UsableKey) if !ok { @@ -354,17 +306,6 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) if err != nil { return err } - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "product_warehouse_id": ctxRow.ProductWarehouseID, - "current_usage_qty": ctxRow.UsageQty, - "current_pending_qty": ctxRow.PendingQty, - "current_total_qty": ctxRow.UsageQty + ctxRow.PendingQty, - }).Debug("fifo release context") - } - var usageDelta, pendingDelta float64 if ctxRow.UsageQty > 0 { if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil { @@ -380,15 +321,6 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) return err } - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "usage_delta": usageDelta, - "pending_delta": pendingDelta, - }).Debug("fifo release applied") - } - return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB { return s.txOrDB(tx, db) }) diff --git a/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql new file mode 100644 index 00000000..b702016c --- /dev/null +++ b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql @@ -0,0 +1,86 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang; + END IF; +END $$; + +ALTER TABLE project_flock_kandang_uniformity + ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs (id) + ON DELETE RESTRICT ON UPDATE CASCADE; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = 'project_budgets' + ) THEN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_budgets_project_flock_id' + ) THEN + ALTER TABLE project_budgets + DROP CONSTRAINT fk_project_budgets_project_flock_id; + END IF; + + ALTER TABLE project_budgets + ADD CONSTRAINT fk_project_budgets_project_flock_id + FOREIGN KEY (project_flock_id) + REFERENCES project_flocks(id); + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = 'project_flock_kandang_uniformity' + ) THEN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'created_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW(); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'updated_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW(); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'deleted_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN deleted_at TIMESTAMPTZ; + END IF; + END IF; +END $$; + +COMMIT; diff --git a/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql new file mode 100644 index 00000000..7a092012 --- /dev/null +++ b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql @@ -0,0 +1,90 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang; + END IF; +END $$; + +ALTER TABLE project_flock_kandang_uniformity + ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs (id) + ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = 'project_budgets' + ) THEN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_budgets_project_flock_id' + ) THEN + ALTER TABLE project_budgets + DROP CONSTRAINT fk_project_budgets_project_flock_id; + END IF; + + ALTER TABLE project_budgets + ADD CONSTRAINT fk_project_budgets_project_flock_id + FOREIGN KEY (project_flock_id) + REFERENCES project_flocks(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_trigger + WHERE tgname = 'trg_soft_delete_fk_project_flock_kandang_uniformity' + ) THEN + DROP TRIGGER trg_soft_delete_fk_project_flock_kandang_uniformity + ON project_flock_kandang_uniformity; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'created_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN created_at; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'updated_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN updated_at; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'deleted_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN deleted_at; + END IF; +END $$; + +COMMIT; diff --git a/internal/entities/project_flock_kandang_uniformity.go b/internal/entities/project_flock_kandang_uniformity.go index ecf90d19..bf320c72 100644 --- a/internal/entities/project_flock_kandang_uniformity.go +++ b/internal/entities/project_flock_kandang_uniformity.go @@ -1,10 +1,6 @@ package entities -import ( - "time" - - "gorm.io/gorm" -) +import "time" type ProjectFlockKandangUniformity struct { Id uint `gorm:"primaryKey"` @@ -18,9 +14,6 @@ type ProjectFlockKandangUniformity struct { UniformQty float64 `gorm:"type:numeric(15,3)"` NotUniformQty float64 `gorm:"type:numeric(15,3)"` UniformDate *time.Time `gorm:"type:timestamptz"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` CreatedBy uint `gorm:"not null"` ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 1e859e47..ec887eea 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -22,6 +22,7 @@ import ( pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -866,6 +867,14 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * } if len(pfkIDs) > 0 { + uniformityRepo := uniformityRepository.NewUniformityRepository(s.Repository.DB()) + if dbTransaction != nil { + uniformityRepo = uniformityRepository.NewUniformityRepository(dbTransaction) + } + if err := uniformityRepo.DeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove uniformity data for project flock kandang") + } + pwRepo := s.ProductWarehouseRepo if dbTransaction != nil { pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index c9ca74f5..54052518 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -333,13 +333,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to list existing stocks: %+v", err) return err } - if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { - s.Log.WithFields(logrus.Fields{ - "recording_id": recordingEntity.Id, - "existing": summarizeExistingStocks(existingStocks), - "incoming": summarizeIncomingStocks(req.Stocks), - }).Debug("recording update stock comparison") - } if stocksMatch(existingStocks, req.Stocks) { hasStockChanges = false } @@ -698,16 +691,6 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. } desiredTotal := desired + pending - if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { - s.Log.WithFields(logrus.Fields{ - "recording_stock_id": stock.Id, - "product_warehouse_id": stock.ProductWarehouseId, - "desired_usage_qty": desired, - "desired_pending_qty": pending, - "desired_total_qty": desiredTotal, - }).Debug("recording fifo consume start") - } - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: recordingStockUsableKey, UsableID: stock.Id, @@ -721,17 +704,6 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. return err } - if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { - s.Log.WithFields(logrus.Fields{ - "recording_stock_id": stock.Id, - "product_warehouse_id": stock.ProductWarehouseId, - "result_usage_qty": result.UsageQuantity, - "result_pending_qty": result.PendingQuantity, - "released_qty": result.ReleasedQuantity, - "added_allocations": len(result.AddedAllocations), - }).Debug("recording fifo consume result") - } - if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } @@ -754,23 +726,6 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm. continue } - var usage float64 - var pending float64 - if stock.UsageQty != nil { - usage = *stock.UsageQty - } - if stock.PendingQty != nil { - pending = *stock.PendingQty - } - if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) { - s.Log.WithFields(logrus.Fields{ - "recording_stock_id": stock.Id, - "product_warehouse_id": stock.ProductWarehouseId, - "current_usage_qty": usage, - "current_pending_qty": pending, - }).Debug("recording fifo release start") - } - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ UsableKey: recordingStockUsableKey, UsableID: stock.Id, diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go index 4a813b98..0c38d81b 100644 --- a/internal/modules/production/uniformities/dto/uniformity.dto.go +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -74,7 +74,6 @@ type UniformityListDTO struct { MeanDown float64 `json:"mean_down"` StandardMeanWeight *float64 `json:"standard_mean_weight"` StandardUniformity *float64 `json:"standard_uniformity"` - CreatedAt time.Time `json:"created_at"` CreatedBy uint `json:"created_by"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } @@ -154,7 +153,6 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor UniformQty: item.UniformQty, MeanUp: item.MeanUp, MeanDown: item.MeanDown, - CreatedAt: item.CreatedAt, CreatedBy: item.CreatedBy, LatestApproval: latestApproval, } diff --git a/internal/modules/production/uniformities/repositories/uniformity.repository.go b/internal/modules/production/uniformities/repositories/uniformity.repository.go index 3bc66f4f..241dea49 100644 --- a/internal/modules/production/uniformities/repositories/uniformity.repository.go +++ b/internal/modules/production/uniformities/repositories/uniformity.repository.go @@ -1,6 +1,8 @@ package repository import ( + "context" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -8,6 +10,7 @@ import ( type UniformityRepository interface { repository.BaseRepository[entity.ProjectFlockKandangUniformity] + DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error } type UniformityRepositoryImpl struct { @@ -19,3 +22,13 @@ func NewUniformityRepository(db *gorm.DB) UniformityRepository { BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockKandangUniformity](db), } } + +func (r *UniformityRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error { + if len(projectFlockKandangIDs) == 0 { + return nil + } + return r.DB().WithContext(ctx). + Unscoped(). + Where("project_flock_kandang_id IN ?", projectFlockKandangIDs). + Delete(&entity.ProjectFlockKandangUniformity{}).Error +} diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 318fabc0..fb7ed9ed 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -99,7 +99,7 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent if params.Week != 0 { db = db.Where("week = ?", params.Week) } - return db.Order("uniform_date DESC").Order("created_at DESC") + return db.Order("uniform_date DESC").Order("id DESC") }) if err != nil { diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index d9b32cd1..977b4ac1 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -180,7 +180,10 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { req.Items = []validation.ReceivePurchaseItemRequest{singleItem} } } - req.TravelDocuments = form.File["documents"] + req.TravelDocuments = form.File["travel_documents"] + if len(req.TravelDocuments) == 0 { + req.TravelDocuments = form.File["documents"] + } result, err := ctrl.service.ReceiveProducts(c, uint(id), req) if err != nil { return err diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 7dac0e19..68b21d6a 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -999,6 +999,22 @@ func (s *purchaseService) uploadTravelDocument( return "", errors.New("document service not available") } + documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(itemID)) + if err != nil { + return "", err + } + if len(documents) > 0 { + var ids []uint + for _, doc := range documents { + if doc.Type == string(utils.DocumentTypePurchaseTravel) { + ids = append(ids, doc.Id) + } + } + if err := s.DocumentSvc.DeleteDocuments(ctx, ids, true); err != nil { + return "", err + } + } + documentFiles := []commonSvc.DocumentFile{{ File: file, Type: string(utils.DocumentTypePurchaseTravel), @@ -1015,7 +1031,7 @@ func (s *purchaseService) uploadTravelDocument( if len(results) == 0 { return "", errors.New("upload result is empty") } - return results[0].URL, nil + return results[0].Document.Path, nil } func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { @@ -1499,10 +1515,56 @@ func (s *purchaseService) loadPurchase( if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) } + s.applyTravelDocumentURLs(ctx, purchase) return purchase, nil } +func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase *entity.Purchase) { + if purchase == nil || s.DocumentSvc == nil { + return + } + + for i := range purchase.Items { + item := &purchase.Items[i] + documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id)) + if err != nil { + s.Log.Warnf("Unable to load travel documents for purchase item %d: %+v", item.Id, err) + } else { + var targetDoc *entity.Document + for j := len(documents) - 1; j >= 0; j-- { + if documents[j].Type == string(utils.DocumentTypePurchaseTravel) { + targetDoc = &documents[j] + break + } + } + if targetDoc != nil { + url, err := s.DocumentSvc.PresignURL(ctx, *targetDoc, 15*time.Minute) + if err != nil { + s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err) + } else if url != "" { + item.TravelNumberDocs = &url + continue + } + } + } + + path := item.TravelNumberDocs + if path == nil || strings.TrimSpace(*path) == "" { + continue + } + url, err := commonSvc.ResolveDocumentURL(ctx, s.DocumentSvc, *path, 15*time.Minute) + if err != nil { + s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err) + continue + } + if url == "" { + continue + } + item.TravelNumberDocs = &url + } +} + func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { seen := make(map[uint]struct{}) ids := make([]uint, 0) diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 34334166..6ec50447 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -426,12 +426,12 @@ const ( DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" - DocumentTypePurchaseTravel DocumentType = "PURCHASE_TRAVEL_DOCUMENT" + DocumentTypePurchaseTravel DocumentType = "PURCHASE_TRAVEL_DOCUMENT" DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" DocumentableTypeExpense DocumentableType = "EXPENSE" DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" - DocumentableTypePurchaseItem DocumentableType = "PURCHASE_ITEM" + DocumentableTypePurchaseItem DocumentableType = "PURCHASE_ITEM" ) // ------------------------------------------------------------------- From df504e3ff03cc351788ce2d571babbc31047f3c7 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 5 Jan 2026 17:17:25 +0700 Subject: [PATCH 22/29] add migration;add api create employee --- ...44_create_daily_checklists_tables.down.sql | 12 + ...1644_create_daily_checklists_tables.up.sql | 194 ++++++++++++++++ internal/entities/employee.go | 26 +++ internal/entities/phase.go | 41 ++++ .../controllers/employees.controller.go | 144 ++++++++++++ .../master/employees/dto/employees.dto.go | 70 ++++++ internal/modules/master/employees/module.go | 25 +++ .../repositories/employees.repository.go | 21 ++ internal/modules/master/employees/route.go | 23 ++ .../employees/services/employees.service.go | 209 ++++++++++++++++++ .../validations/employees.validation.go | 19 ++ internal/modules/master/route.go | 4 +- 12 files changed, 787 insertions(+), 1 deletion(-) create mode 100644 internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql create mode 100644 internal/database/migrations/20260105131644_create_daily_checklists_tables.up.sql create mode 100644 internal/entities/employee.go create mode 100644 internal/entities/phase.go create mode 100644 internal/modules/master/employees/controllers/employees.controller.go create mode 100644 internal/modules/master/employees/dto/employees.dto.go create mode 100644 internal/modules/master/employees/module.go create mode 100644 internal/modules/master/employees/repositories/employees.repository.go create mode 100644 internal/modules/master/employees/route.go create mode 100644 internal/modules/master/employees/services/employees.service.go create mode 100644 internal/modules/master/employees/validations/employees.validation.go diff --git a/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql b/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql new file mode 100644 index 00000000..7be30be1 --- /dev/null +++ b/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS daily_checklist_tasks; +DROP TABLE IF EXISTS daily_checklist_activity_task_assignees; +DROP TABLE IF EXISTS daily_checklist_activity_tasks; +DROP TABLE IF EXISTS daily_checklist_phases; +DROP TABLE IF EXISTS daily_checklists; +DROP TABLE IF EXISTS checklists; +DROP TABLE IF EXISTS phase_activities; +DROP TABLE IF EXISTS phases; +DROP TABLE IF EXISTS employee_kandangs; +DROP TABLE IF EXISTS employees; + +DROP TYPE IF EXISTS category_code; diff --git a/internal/database/migrations/20260105131644_create_daily_checklists_tables.up.sql b/internal/database/migrations/20260105131644_create_daily_checklists_tables.up.sql new file mode 100644 index 00000000..6074fa8c --- /dev/null +++ b/internal/database/migrations/20260105131644_create_daily_checklists_tables.up.sql @@ -0,0 +1,194 @@ +CREATE TYPE category_code AS ENUM ( + 'pullet_open', + 'pullet_close', + 'produksi_open', + 'produksi_close' +); + +-- MASTER TABLES + +CREATE TABLE employees ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE employee_kandangs ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + employee_id bigint NOT NULL, + kandang_id bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_employee_kandangs_employee + FOREIGN KEY (employee_id) REFERENCES employees(id) + ON DELETE CASCADE, + + CONSTRAINT fk_employee_kandangs_kandang + FOREIGN KEY (kandang_id) REFERENCES kandangs(id) + ON DELETE CASCADE, + + CONSTRAINT uq_employee_kandangs UNIQUE (employee_id, kandang_id) +); + +-- PHASE & CHECKLIST + +CREATE TABLE phases ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar NOT NULL, + is_active boolean NOT NULL DEFAULT true, + category category_code NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE phase_activities ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + phase_id bigint NOT NULL, + name varchar NOT NULL, + description text, + time_type text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_phase_activities_phase + FOREIGN KEY (phase_id) REFERENCES phases(id) + ON DELETE CASCADE +); + +CREATE TABLE checklists ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar NOT NULL, + description text, + phase_id bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + + CONSTRAINT fk_checklists_phase + FOREIGN KEY (phase_id) REFERENCES phases(id) + ON DELETE SET NULL +); + + +-- DAILY CHECKLISTS +CREATE TABLE daily_checklists ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + kandang_id bigint NOT NULL, + checklist_id bigint NOT NULL, + date date NOT NULL, + name varchar, + status varchar, + category category_code NOT NULL, + total_score integer, + document_path varchar, + reject_reason text, + created_by bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_daily_checklists_kandang + FOREIGN KEY (kandang_id) REFERENCES kandangs(id) + ON DELETE CASCADE, + + CONSTRAINT fk_daily_checklists_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) + ON DELETE RESTRICT, + + CONSTRAINT fk_daily_checklists_created_by + FOREIGN KEY (created_by) REFERENCES users(id) + ON DELETE SET NULL +); + + +--RELASI CHECKLIST ⇄ PHASE + +CREATE TABLE daily_checklist_phases ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + checklist_id bigint NOT NULL, + phase_id bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_dcp_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) + ON DELETE CASCADE, + + CONSTRAINT fk_dcp_phase + FOREIGN KEY (phase_id) REFERENCES phases(id) + ON DELETE CASCADE, + + CONSTRAINT uq_daily_checklist_phases UNIQUE (checklist_id, phase_id) +); + + +--ACTIVITY TASKS & ASSIGNMENT + + +CREATE TABLE daily_checklist_activity_tasks ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + checklist_id bigint NOT NULL, + phase_id bigint NOT NULL, + phase_activity_id bigint NOT NULL, + time_type text, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_dcat_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) + ON DELETE CASCADE, + + CONSTRAINT fk_dcat_phase + FOREIGN KEY (phase_id) REFERENCES phases(id) + ON DELETE CASCADE, + + CONSTRAINT fk_dcat_phase_activity + FOREIGN KEY (phase_activity_id) REFERENCES phase_activities(id) + ON DELETE CASCADE +); + +CREATE TABLE daily_checklist_activity_task_assignments ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_id bigint NOT NULL, + employee_id bigint NOT NULL, + checked boolean NOT NULL DEFAULT false, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_assignment_task + FOREIGN KEY (task_id) REFERENCES daily_checklist_activity_tasks(id) + ON DELETE CASCADE, + + CONSTRAINT fk_assignment_employee + FOREIGN KEY (employee_id) REFERENCES employees(id) + ON DELETE CASCADE +); + +--DAILY CHECKLIST TASK RESULT +CREATE TABLE daily_checklist_tasks ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + daily_checklist_id bigint NOT NULL, + checklist_id bigint NOT NULL, + checklist_item_id bigint, + is_completed boolean NOT NULL DEFAULT false, + score_value integer, + notes text, + photo_proof varchar, + status varchar, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_dct_daily + FOREIGN KEY (daily_checklist_id) REFERENCES daily_checklists(id) + ON DELETE CASCADE, + + CONSTRAINT fk_dct_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) + ON DELETE CASCADE, + + CONSTRAINT fk_dct_checklist_item + FOREIGN KEY (checklist_item_id) REFERENCES phase_activities(id) + ON DELETE SET NULL +); diff --git a/internal/entities/employee.go b/internal/entities/employee.go new file mode 100644 index 00000000..5810c6ee --- /dev/null +++ b/internal/entities/employee.go @@ -0,0 +1,26 @@ +package entities + +import "time" + +type Employee struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + IsActive bool `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + EmployeeKandangs []EmployeeKandang `gorm:"foreignKey:EmployeeId;references:Id"` +} + +type Employees = Employee + +type EmployeeKandang struct { + Id uint `gorm:"primaryKey"` + EmployeeId uint `gorm:"not null"` + KandangId uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Employee Employee `gorm:"foreignKey:EmployeeId;references:Id"` + Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` +} diff --git a/internal/entities/phase.go b/internal/entities/phase.go new file mode 100644 index 00000000..4ee80804 --- /dev/null +++ b/internal/entities/phase.go @@ -0,0 +1,41 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Phase struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + IsActive bool `gorm:"not null;default:true"` + Category string `gorm:"type:category_code;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + + Activities []PhaseActivity `gorm:"foreignKey:PhaseId;references:Id"` +} + +type PhaseActivity struct { + Id uint `gorm:"primaryKey"` + PhaseId uint `gorm:"not null"` + Name string `gorm:"not null"` + Description *string `gorm:"type:text"` + TimeType *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Phase Phase `gorm:"foreignKey:PhaseId;references:Id"` +} + +type Checklist struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + Description *string `gorm:"type:text"` + PhaseId *uint + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Phase *Phase `gorm:"foreignKey:PhaseId;references:Id"` +} diff --git a/internal/modules/master/employees/controllers/employees.controller.go b/internal/modules/master/employees/controllers/employees.controller.go new file mode 100644 index 00000000..6be28200 --- /dev/null +++ b/internal/modules/master/employees/controllers/employees.controller.go @@ -0,0 +1,144 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type EmployeesController struct { + EmployeesService service.EmployeesService +} + +func NewEmployeesController(employeesService service.EmployeesService) *EmployeesController { + return &EmployeesController{ + EmployeesService: employeesService, + } +} + +func (u *EmployeesController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + 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.EmployeesService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.EmployeesListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all employeess successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToEmployeesListDTOs(result), + }) +} + +func (u *EmployeesController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.EmployeesService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get employees successfully", + Data: dto.ToEmployeesListDTO(*result), + }) +} + +func (u *EmployeesController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.EmployeesService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create employees successfully", + Data: dto.ToEmployeesListDTO(*result), + }) +} + +func (u *EmployeesController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.EmployeesService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update employees successfully", + Data: dto.ToEmployeesListDTO(*result), + }) +} + +func (u *EmployeesController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.EmployeesService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete employees successfully", + }) +} diff --git a/internal/modules/master/employees/dto/employees.dto.go b/internal/modules/master/employees/dto/employees.dto.go new file mode 100644 index 00000000..65b1b5ca --- /dev/null +++ b/internal/modules/master/employees/dto/employees.dto.go @@ -0,0 +1,70 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" +) + +// === DTO Structs === + +type EmployeesRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type EmployeesListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + Kandangs []kandangDTO.KandangRelationDTO `json:"kandangs"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type EmployeesDetailDTO struct { + EmployeesListDTO +} + +// === Mapper Functions === + +func ToEmployeesRelationDTO(e entity.Employees) EmployeesRelationDTO { + return EmployeesRelationDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToEmployeesListDTO(e entity.Employees) EmployeesListDTO { + kandangs := make([]kandangDTO.KandangRelationDTO, 0, len(e.EmployeeKandangs)) + for _, rel := range e.EmployeeKandangs { + if rel.Kandang.Id == 0 { + continue + } + kandangs = append(kandangs, kandangDTO.ToKandangRelationDTO(rel.Kandang)) + } + + return EmployeesListDTO{ + Id: e.Id, + Name: e.Name, + IsActive: e.IsActive, + Kandangs: kandangs, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToEmployeesListDTOs(e []entity.Employees) []EmployeesListDTO { + result := make([]EmployeesListDTO, len(e)) + for i, r := range e { + result[i] = ToEmployeesListDTO(r) + } + return result +} + +func ToEmployeesDetailDTO(e entity.Employees) EmployeesDetailDTO { + return EmployeesDetailDTO{ + EmployeesListDTO: ToEmployeesListDTO(e), + } +} diff --git a/internal/modules/master/employees/module.go b/internal/modules/master/employees/module.go new file mode 100644 index 00000000..a916ced6 --- /dev/null +++ b/internal/modules/master/employees/module.go @@ -0,0 +1,25 @@ +package employeess + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rEmployees "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/repositories" + sEmployees "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type EmployeesModule struct{} + +func (EmployeesModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + employeesRepo := rEmployees.NewEmployeesRepository(db) + userRepo := rUser.NewUserRepository(db) + + employeesService := sEmployees.NewEmployeesService(employeesRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + EmployeesRoutes(router, userService, employeesService) +} diff --git a/internal/modules/master/employees/repositories/employees.repository.go b/internal/modules/master/employees/repositories/employees.repository.go new file mode 100644 index 00000000..f10a5884 --- /dev/null +++ b/internal/modules/master/employees/repositories/employees.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type EmployeesRepository interface { + repository.BaseRepository[entity.Employees] +} + +type EmployeesRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Employees] +} + +func NewEmployeesRepository(db *gorm.DB) EmployeesRepository { + return &EmployeesRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Employees](db), + } +} diff --git a/internal/modules/master/employees/route.go b/internal/modules/master/employees/route.go new file mode 100644 index 00000000..53974814 --- /dev/null +++ b/internal/modules/master/employees/route.go @@ -0,0 +1,23 @@ +package employeess + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/controllers" + employees "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func EmployeesRoutes(v1 fiber.Router, u user.UserService, s employees.EmployeesService) { + ctrl := controller.NewEmployeesController(s) + + route := v1.Group("/employees") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/employees/services/employees.service.go b/internal/modules/master/employees/services/employees.service.go new file mode 100644 index 00000000..c17f941a --- /dev/null +++ b/internal/modules/master/employees/services/employees.service.go @@ -0,0 +1,209 @@ +package service + +import ( + "errors" + "fmt" + "strconv" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type EmployeesService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Employees, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Employees, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Employees, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Employees, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type employeesService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.EmployeesRepository +} + +func NewEmployeesService(repo repository.EmployeesRepository, validate *validator.Validate) EmployeesService { + return &employeesService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s employeesService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("EmployeeKandangs.Kandang"). + Preload("EmployeeKandangs.Kandang.Location"). + Preload("EmployeeKandangs.Kandang.Pic"). + Preload("EmployeeKandangs.Kandang.CreatedUser") +} + +func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Employees, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + employeess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get employeess: %+v", err) + return nil, 0, err + } + return employeess, total, nil +} + +func (s employeesService) GetOne(c *fiber.Ctx, id uint) (*entity.Employees, error) { + employees, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Employees not found") + } + if err != nil { + s.Log.Errorf("Failed get employees by id: %+v", err) + return nil, err + } + return employees, nil +} + +func (s *employeesService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Employees, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty") + } + + kandangIDs, err := parseKandangIDs(req.KandangIDs) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("LOWER(name) = ?", strings.ToLower(name)) + }); err == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "employee already exists") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed checking employee uniqueness: %+v", err) + return nil, err + } + + createBody := &entity.Employees{ + Name: name, + IsActive: req.IsActive, + } + + if err := s.Repository.DB().Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + relations := make([]entity.EmployeeKandang, 0, len(kandangIDs)) + for _, kandangID := range kandangIDs { + relations = append(relations, entity.EmployeeKandang{ + EmployeeId: createBody.Id, + KandangId: kandangID, + }) + } + + if len(relations) > 0 { + if err := tx.WithContext(c.Context()).Create(&relations).Error; err != nil { + return err + } + } + + return nil + }); err != nil { + s.Log.Errorf("Failed to create employees: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Employees, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + updateBody["name"] = *req.Name + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Employees not found") + } + s.Log.Errorf("Failed to update employees: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s employeesService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Employees not found") + } + s.Log.Errorf("Failed to delete employees: %+v", err) + return err + } + return nil +} + +func parseKandangIDs(raw string) ([]uint, error) { + parts := strings.Split(raw, ",") + ids := make([]uint, 0, len(parts)) + seen := make(map[uint]struct{}) + + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid kandang id: %s", value) + } + + id := uint(parsed) + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + + if len(ids) == 0 { + return nil, errors.New("kandang_ids must contain at least one valid id") + } + + return ids, nil +} diff --git a/internal/modules/master/employees/validations/employees.validation.go b/internal/modules/master/employees/validations/employees.validation.go new file mode 100644 index 00000000..4449bfcc --- /dev/null +++ b/internal/modules/master/employees/validations/employees.validation.go @@ -0,0 +1,19 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + KandangIDs string `json:"kandang_ids" validate:"required"` + IsActive bool `json:"is_active"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` + KandangIDs *string `json:"kandang_ids,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 26ae28ee..2965baae 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -10,17 +10,18 @@ import ( areas "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas" banks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks" customers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers" + employeess "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees" fcrs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs" flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks" kandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs" locations "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations" nonstocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks" productcategories "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories" + productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards" products "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products" suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" - productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards" // MODULE IMPORTS ) @@ -42,6 +43,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida banks.BankModule{}, flocks.FlockModule{}, productionStandards.ProductionStandardModule{}, + employeess.EmployeesModule{}, // MODULE REGISTRY } From 80109b77db6cde912ecf1312511b882accba0a11 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 5 Jan 2026 17:32:41 +0700 Subject: [PATCH 23/29] adjust api get all employees --- .../controllers/employees.controller.go | 19 ++++++++++++++++++- .../employees/services/employees.service.go | 19 +++++++++++++------ .../validations/employees.validation.go | 8 +++++--- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/internal/modules/master/employees/controllers/employees.controller.go b/internal/modules/master/employees/controllers/employees.controller.go index 6be28200..3d0901c8 100644 --- a/internal/modules/master/employees/controllers/employees.controller.go +++ b/internal/modules/master/employees/controllers/employees.controller.go @@ -29,10 +29,27 @@ func (u *EmployeesController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } - if query.Page < 1 || query.Limit < 1 { + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } + if kandangParam := c.Query("kandang_id", ""); kandangParam != "" { + id, err := strconv.Atoi(kandangParam) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid kandang_id") + } + temp := uint(id) + query.KandangId = &temp + } + + if activeParam := c.Query("is_active", ""); activeParam != "" { + value, err := strconv.ParseBool(activeParam) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid is_active value") + } + query.IsActive = &value + } + result, totalResults, err := u.EmployeesService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/employees/services/employees.service.go b/internal/modules/master/employees/services/employees.service.go index c17f941a..df131c23 100644 --- a/internal/modules/master/employees/services/employees.service.go +++ b/internal/modules/master/employees/services/employees.service.go @@ -41,10 +41,10 @@ func NewEmployeesService(repo repository.EmployeesRepository, validate *validato func (s employeesService) withRelations(db *gorm.DB) *gorm.DB { return db. - Preload("EmployeeKandangs.Kandang"). - Preload("EmployeeKandangs.Kandang.Location"). - Preload("EmployeeKandangs.Kandang.Pic"). - Preload("EmployeeKandangs.Kandang.CreatedUser") + Preload("EmployeeKandangs.Kandang") + // Preload("EmployeeKandangs.Kandang.Location"). + // Preload("EmployeeKandangs.Kandang.Pic"). + // Preload("EmployeeKandangs.Kandang.CreatedUser") } func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Employees, int64, error) { @@ -57,9 +57,16 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti employeess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + db = db.Where("employees.name LIKE ?", "%"+params.Search+"%") } - return db.Order("created_at DESC").Order("updated_at DESC") + if params.KandangId != nil { + db = db.Joins("JOIN employee_kandangs ek ON ek.employee_id = employees.id"). + Where("ek.kandang_id = ?", *params.KandangId) + } + if params.IsActive != nil { + db = db.Where("employees.is_active = ?", *params.IsActive) + } + return db.Order("employees.created_at DESC").Order("employees.updated_at DESC") }) if err != nil { diff --git a/internal/modules/master/employees/validations/employees.validation.go b/internal/modules/master/employees/validations/employees.validation.go index 4449bfcc..159b875f 100644 --- a/internal/modules/master/employees/validations/employees.validation.go +++ b/internal/modules/master/employees/validations/employees.validation.go @@ -13,7 +13,9 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + KandangId *uint `query:"kandang_id" validate:"omitempty"` + IsActive *bool `query:"is_active" validate:"omitempty"` } From 9f840f265029236d43e937ff8fa5daec6471bac3 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 5 Jan 2026 17:49:44 +0700 Subject: [PATCH 24/29] adjust patch employee --- .../employees/services/employees.service.go | 70 ++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/internal/modules/master/employees/services/employees.service.go b/internal/modules/master/employees/services/employees.service.go index df131c23..aa82255d 100644 --- a/internal/modules/master/employees/services/employees.service.go +++ b/internal/modules/master/employees/services/employees.service.go @@ -153,16 +153,80 @@ func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } updateBody := make(map[string]any) + var ( + kandangIDs []uint + needKandangUpdate bool + ) if req.Name != nil { - updateBody["name"] = *req.Name + trimmed := strings.TrimSpace(*req.Name) + if trimmed == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty") + } + + if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("LOWER(name) = ? AND id <> ?", strings.ToLower(trimmed), id) + }); err == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "employee already exists") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed checking employee uniqueness: %+v", err) + return nil, err + } + + updateBody["name"] = trimmed } - if len(updateBody) == 0 { + if req.IsActive != nil { + updateBody["is_active"] = *req.IsActive + } + + if req.KandangIDs != nil { + ids, err := parseKandangIDs(*req.KandangIDs) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + kandangIDs = ids + needKandangUpdate = true + } + + if len(updateBody) == 0 && !needKandangUpdate { return s.GetOne(c, id) } - if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if err := s.Repository.DB().Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if len(updateBody) > 0 { + if err := repoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return err + } + } + + if needKandangUpdate { + if err := tx.WithContext(c.Context()). + Where("employee_id = ?", id). + Delete(&entity.EmployeeKandang{}).Error; err != nil { + return err + } + + relations := make([]entity.EmployeeKandang, 0, len(kandangIDs)) + for _, kandangID := range kandangIDs { + relations = append(relations, entity.EmployeeKandang{ + EmployeeId: id, + KandangId: kandangID, + }) + } + + if len(relations) > 0 { + if err := tx.WithContext(c.Context()).Create(&relations).Error; err != nil { + return err + } + } + } + + return nil + }); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Employees not found") } From 4a08be1f55d05500121417f5b375fad1808a9d05 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 5 Jan 2026 19:59:03 +0700 Subject: [PATCH 25/29] add module master data phases --- internal/entities/phase.go | 2 +- .../phasess/controllers/phases.controller.go | 148 +++++++++++++++++ .../modules/master/phasess/dto/phases.dto.go | 68 ++++++++ internal/modules/master/phasess/module.go | 25 +++ .../phasess/repositories/phases.repository.go | 21 +++ internal/modules/master/phasess/route.go | 23 +++ .../master/phasess/services/phases.service.go | 152 ++++++++++++++++++ .../phasess/validations/phases.validation.go | 17 ++ internal/modules/master/route.go | 2 + 9 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 internal/modules/master/phasess/controllers/phases.controller.go create mode 100644 internal/modules/master/phasess/dto/phases.dto.go create mode 100644 internal/modules/master/phasess/module.go create mode 100644 internal/modules/master/phasess/repositories/phases.repository.go create mode 100644 internal/modules/master/phasess/route.go create mode 100644 internal/modules/master/phasess/services/phases.service.go create mode 100644 internal/modules/master/phasess/validations/phases.validation.go diff --git a/internal/entities/phase.go b/internal/entities/phase.go index 4ee80804..d30369eb 100644 --- a/internal/entities/phase.go +++ b/internal/entities/phase.go @@ -6,7 +6,7 @@ import ( "gorm.io/gorm" ) -type Phase struct { +type Phases struct { Id uint `gorm:"primaryKey"` Name string `gorm:"not null"` IsActive bool `gorm:"not null;default:true"` diff --git a/internal/modules/master/phasess/controllers/phases.controller.go b/internal/modules/master/phasess/controllers/phases.controller.go new file mode 100644 index 00000000..c9d9d349 --- /dev/null +++ b/internal/modules/master/phasess/controllers/phases.controller.go @@ -0,0 +1,148 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PhasesController struct { + PhasesService service.PhasesService +} + +func NewPhasesController(phasesService service.PhasesService) *PhasesController { + return &PhasesController{ + PhasesService: phasesService, + } +} + +func (u *PhasesController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if category := c.Query("category", ""); category != "" { + query.Category = &category + } + + result, totalResults, err := u.PhasesService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.PhasesListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all phasess successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToPhasesListDTOs(result), + }) +} + +func (u *PhasesController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.PhasesService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get phases successfully", + Data: dto.ToPhasesListDTO(*result), + }) +} + +func (u *PhasesController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PhasesService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create phases successfully", + Data: dto.ToPhasesListDTO(*result), + }) +} + +func (u *PhasesController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PhasesService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update phases successfully", + Data: dto.ToPhasesListDTO(*result), + }) +} + +func (u *PhasesController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.PhasesService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete phases successfully", + }) +} diff --git a/internal/modules/master/phasess/dto/phases.dto.go b/internal/modules/master/phasess/dto/phases.dto.go new file mode 100644 index 00000000..51724556 --- /dev/null +++ b/internal/modules/master/phasess/dto/phases.dto.go @@ -0,0 +1,68 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type PhasesRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type PhasesListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + IsActive bool `json:"is_active"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` +} + +type PhasesDetailDTO struct { + PhasesListDTO +} + +// === Mapper Functions === + +func ToPhasesRelationDTO(e entity.Phases) PhasesRelationDTO { + return PhasesRelationDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToPhasesListDTO(e entity.Phases) PhasesListDTO { + var createdUser *userDTO.UserRelationDTO + // if e.CreatedUser.Id != 0 { + // mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + // createdUser = &mapped + // } + + return PhasesListDTO{ + Id: e.Id, + Name: e.Name, + Category: e.Category, + IsActive: e.IsActive, + CreatedAt: e.CreatedAt, + CreatedUser: createdUser, + } +} + +func ToPhasesListDTOs(e []entity.Phases) []PhasesListDTO { + result := make([]PhasesListDTO, len(e)) + for i, r := range e { + result[i] = ToPhasesListDTO(r) + } + return result +} + +func ToPhasesDetailDTO(e entity.Phases) PhasesDetailDTO { + return PhasesDetailDTO{ + PhasesListDTO: ToPhasesListDTO(e), + } +} diff --git a/internal/modules/master/phasess/module.go b/internal/modules/master/phasess/module.go new file mode 100644 index 00000000..3f44c220 --- /dev/null +++ b/internal/modules/master/phasess/module.go @@ -0,0 +1,25 @@ +package phases + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + sPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type PhasesModule struct{} + +func (PhasesModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + phasesRepo := rPhases.NewPhasesRepository(db) + userRepo := rUser.NewUserRepository(db) + + phasesService := sPhases.NewPhasesService(phasesRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + PhasesRoutes(router, userService, phasesService) +} diff --git a/internal/modules/master/phasess/repositories/phases.repository.go b/internal/modules/master/phasess/repositories/phases.repository.go new file mode 100644 index 00000000..d243ca2e --- /dev/null +++ b/internal/modules/master/phasess/repositories/phases.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type PhasesRepository interface { + repository.BaseRepository[entity.Phases] +} + +type PhasesRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Phases] +} + +func NewPhasesRepository(db *gorm.DB) PhasesRepository { + return &PhasesRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Phases](db), + } +} diff --git a/internal/modules/master/phasess/route.go b/internal/modules/master/phasess/route.go new file mode 100644 index 00000000..b4ca202d --- /dev/null +++ b/internal/modules/master/phasess/route.go @@ -0,0 +1,23 @@ +package phases + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/controllers" + phases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func PhasesRoutes(v1 fiber.Router, u user.UserService, s phases.PhasesService) { + ctrl := controller.NewPhasesController(s) + + route := v1.Group("/phases") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/phasess/services/phases.service.go b/internal/modules/master/phasess/services/phases.service.go new file mode 100644 index 00000000..863b369d --- /dev/null +++ b/internal/modules/master/phasess/services/phases.service.go @@ -0,0 +1,152 @@ +package service + +import ( + "errors" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type PhasesService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Phases, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Phases, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Phases, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Phases, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type phasesService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.PhasesRepository +} + +func NewPhasesService(repo repository.PhasesRepository, validate *validator.Validate) PhasesService { + return &phasesService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s phasesService) withRelations(db *gorm.DB) *gorm.DB { + return db +} + +func (s phasesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Phases, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + phasess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + if params.Category != nil { + db = db.Where("category = ?", *params.Category) + } + return db.Order("created_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get phasess: %+v", err) + return nil, 0, err + } + return phasess, total, nil +} + +func (s phasesService) GetOne(c *fiber.Ctx, id uint) (*entity.Phases, error) { + phases, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Phases not found") + } + if err != nil { + s.Log.Errorf("Failed get phases by id: %+v", err) + return nil, err + } + return phases, nil +} + +func (s *phasesService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Phases, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("LOWER(name) = ?", strings.ToLower(req.Name)) + }); err == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "phase already exists") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed checking phase uniqueness: %+v", err) + return nil, err + } + + createBody := &entity.Phases{ + Name: req.Name, + Category: req.Category, + IsActive: true, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create phases: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s phasesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Phases, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("LOWER(name) = ? AND id <> ?", strings.ToLower(*req.Name), id) + }); err == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "phase already exists") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed checking phase uniqueness: %+v", err) + return nil, err + } + + updateBody["name"] = strings.TrimSpace(*req.Name) + } + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Phases not found") + } + s.Log.Errorf("Failed to update phases: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s phasesService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Phases not found") + } + s.Log.Errorf("Failed to delete phases: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/phasess/validations/phases.validation.go b/internal/modules/master/phasess/validations/phases.validation.go new file mode 100644 index 00000000..c22d4208 --- /dev/null +++ b/internal/modules/master/phasess/validations/phases.validation.go @@ -0,0 +1,17 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + Category string `json:"category" validate:"required"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + Category *string `query:"category" validate:"omitempty"` +} diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 2965baae..e0a7b246 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -22,6 +22,7 @@ import ( suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" + phasess "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess" // MODULE IMPORTS ) @@ -44,6 +45,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida flocks.FlockModule{}, productionStandards.ProductionStandardModule{}, employeess.EmployeesModule{}, + phasess.PhasesModule{}, // MODULE REGISTRY } From b1996be24c114708062dd10eb2a1b716dd889d17 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 5 Jan 2026 22:25:46 +0700 Subject: [PATCH 26/29] add module master phase activity --- .../controllers/phase-activity.controller.go | 153 ++++++++++++++++ .../dto/phase-activity.dto.go | 72 ++++++++ .../modules/master/phase-activities/module.go | 27 +++ .../repositories/phase-activity.repository.go | 21 +++ .../modules/master/phase-activities/route.go | 23 +++ .../services/phase-activity.service.go | 167 ++++++++++++++++++ .../validations/phase-activity.validation.go | 21 +++ .../master/phasess/services/phases.service.go | 16 +- internal/modules/master/route.go | 4 +- 9 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 internal/modules/master/phase-activities/controllers/phase-activity.controller.go create mode 100644 internal/modules/master/phase-activities/dto/phase-activity.dto.go create mode 100644 internal/modules/master/phase-activities/module.go create mode 100644 internal/modules/master/phase-activities/repositories/phase-activity.repository.go create mode 100644 internal/modules/master/phase-activities/route.go create mode 100644 internal/modules/master/phase-activities/services/phase-activity.service.go create mode 100644 internal/modules/master/phase-activities/validations/phase-activity.validation.go diff --git a/internal/modules/master/phase-activities/controllers/phase-activity.controller.go b/internal/modules/master/phase-activities/controllers/phase-activity.controller.go new file mode 100644 index 00000000..455ff1e4 --- /dev/null +++ b/internal/modules/master/phase-activities/controllers/phase-activity.controller.go @@ -0,0 +1,153 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PhaseActivityController struct { + PhaseActivityService service.PhaseActivityService +} + +func NewPhaseActivityController(phaseActivityService service.PhaseActivityService) *PhaseActivityController { + return &PhaseActivityController{ + PhaseActivityService: phaseActivityService, + } +} + +func (u *PhaseActivityController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if phaseParam := c.Query("phase_id", ""); phaseParam != "" { + id, err := strconv.Atoi(phaseParam) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid phase_id") + } + temp := uint(id) + query.PhaseId = &temp + } + + result, totalResults, err := u.PhaseActivityService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.PhaseActivityListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all phaseActivitys successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToPhaseActivityListDTOs(result), + }) +} + +func (u *PhaseActivityController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.PhaseActivityService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get phaseActivity successfully", + Data: dto.ToPhaseActivityListDTO(*result), + }) +} + +func (u *PhaseActivityController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PhaseActivityService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create phaseActivity successfully", + Data: dto.ToPhaseActivityListDTO(*result), + }) +} + +func (u *PhaseActivityController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PhaseActivityService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update phaseActivity successfully", + Data: dto.ToPhaseActivityListDTO(*result), + }) +} + +func (u *PhaseActivityController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.PhaseActivityService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete phaseActivity successfully", + }) +} diff --git a/internal/modules/master/phase-activities/dto/phase-activity.dto.go b/internal/modules/master/phase-activities/dto/phase-activity.dto.go new file mode 100644 index 00000000..ee5942d5 --- /dev/null +++ b/internal/modules/master/phase-activities/dto/phase-activity.dto.go @@ -0,0 +1,72 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type PhaseActivityRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type PhaseActivityListDTO struct { + Id uint `json:"id"` + PhaseId uint `json:"phase_id"` + Name string `json:"name"` + Description *string `json:"description"` + TimeType *string `json:"time_type"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type PhaseActivityDetailDTO struct { + PhaseActivityListDTO +} + +// === Mapper Functions === + +func ToPhaseActivityRelationDTO(e entity.PhaseActivity) PhaseActivityRelationDTO { + return PhaseActivityRelationDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToPhaseActivityListDTO(e entity.PhaseActivity) PhaseActivityListDTO { + var createdUser *userDTO.UserRelationDTO + // if e.CreatedUser.Id != 0 { + // mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + // createdUser = &mapped + // } + + return PhaseActivityListDTO{ + Id: e.Id, + PhaseId: e.PhaseId, + Name: e.Name, + Description: e.Description, + TimeType: e.TimeType, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToPhaseActivityListDTOs(e []entity.PhaseActivity) []PhaseActivityListDTO { + result := make([]PhaseActivityListDTO, len(e)) + for i, r := range e { + result[i] = ToPhaseActivityListDTO(r) + } + return result +} + +func ToPhaseActivityDetailDTO(e entity.PhaseActivity) PhaseActivityDetailDTO { + return PhaseActivityDetailDTO{ + PhaseActivityListDTO: ToPhaseActivityListDTO(e), + } +} diff --git a/internal/modules/master/phase-activities/module.go b/internal/modules/master/phase-activities/module.go new file mode 100644 index 00000000..22d25189 --- /dev/null +++ b/internal/modules/master/phase-activities/module.go @@ -0,0 +1,27 @@ +package phaseActivity + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rPhaseActivity "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/repositories" + sPhaseActivity "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/services" + rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type PhaseActivityModule struct{} + +func (PhaseActivityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + phaseActivityRepo := rPhaseActivity.NewPhaseActivityRepository(db) + phasesRepo := rPhases.NewPhasesRepository(db) + userRepo := rUser.NewUserRepository(db) + + phaseActivityService := sPhaseActivity.NewPhaseActivityService(phaseActivityRepo, phasesRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + PhaseActivityRoutes(router, userService, phaseActivityService) +} diff --git a/internal/modules/master/phase-activities/repositories/phase-activity.repository.go b/internal/modules/master/phase-activities/repositories/phase-activity.repository.go new file mode 100644 index 00000000..cc5eaae5 --- /dev/null +++ b/internal/modules/master/phase-activities/repositories/phase-activity.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type PhaseActivityRepository interface { + repository.BaseRepository[entity.PhaseActivity] +} + +type PhaseActivityRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.PhaseActivity] +} + +func NewPhaseActivityRepository(db *gorm.DB) PhaseActivityRepository { + return &PhaseActivityRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.PhaseActivity](db), + } +} diff --git a/internal/modules/master/phase-activities/route.go b/internal/modules/master/phase-activities/route.go new file mode 100644 index 00000000..6fcef558 --- /dev/null +++ b/internal/modules/master/phase-activities/route.go @@ -0,0 +1,23 @@ +package phaseActivity + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/controllers" + phaseActivity "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func PhaseActivityRoutes(v1 fiber.Router, u user.UserService, s phaseActivity.PhaseActivityService) { + ctrl := controller.NewPhaseActivityController(s) + + route := v1.Group("/phase-activities") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/phase-activities/services/phase-activity.service.go b/internal/modules/master/phase-activities/services/phase-activity.service.go new file mode 100644 index 00000000..3426eab4 --- /dev/null +++ b/internal/modules/master/phase-activities/services/phase-activity.service.go @@ -0,0 +1,167 @@ +package service + +import ( + "errors" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/validations" + phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type PhaseActivityService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.PhaseActivity, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.PhaseActivity, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.PhaseActivity, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.PhaseActivity, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type phaseActivityService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.PhaseActivityRepository + PhaseRepo phaseRepo.PhasesRepository +} + +func NewPhaseActivityService(repo repository.PhaseActivityRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate) PhaseActivityService { + return &phaseActivityService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + PhaseRepo: phaseRepo, + } +} + +func (s phaseActivityService) withRelations(db *gorm.DB) *gorm.DB { + return db +} + +func (s phaseActivityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.PhaseActivity, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + phaseActivitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + db = db.Where("name LIKE ?", "%"+params.Search+"%") + } + if params.PhaseId != nil { + db = db.Where("phase_id = ?", *params.PhaseId) + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get phaseActivitys: %+v", err) + return nil, 0, err + } + return phaseActivitys, total, nil +} + +func (s phaseActivityService) GetOne(c *fiber.Ctx, id uint) (*entity.PhaseActivity, error) { + phaseActivity, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "PhaseActivity not found") + } + if err != nil { + s.Log.Errorf("Failed get phaseActivity by id: %+v", err) + return nil, err + } + return phaseActivity, nil +} + +func (s *phaseActivityService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.PhaseActivity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + phase, err := s.PhaseRepo.GetByID(c.Context(), req.PhaseId, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, "phase not found") + } + if err != nil { + s.Log.Errorf("Failed to get phase: %+v", err) + return nil, err + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty") + } + + timeType := strings.TrimSpace(req.TimeType) + if timeType == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty") + } + + createBody := &entity.PhaseActivity{ + PhaseId: phase.Id, + Name: name, + Description: req.Description, + TimeType: &timeType, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create phaseActivity: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s phaseActivityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.PhaseActivity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + trimmedName := strings.TrimSpace(req.Name) + if trimmedName == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty") + } + + trimmedTimeType := strings.TrimSpace(req.TimeType) + if trimmedTimeType == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty") + } + + updateBody := map[string]any{ + "name": trimmedName, + "time_type": trimmedTimeType, + } + + if req.Description != nil { + updateBody["description"] = *req.Description + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "PhaseActivity not found") + } + s.Log.Errorf("Failed to update phaseActivity: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s phaseActivityService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "PhaseActivity not found") + } + s.Log.Errorf("Failed to delete phaseActivity: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/phase-activities/validations/phase-activity.validation.go b/internal/modules/master/phase-activities/validations/phase-activity.validation.go new file mode 100644 index 00000000..a2ab8e1b --- /dev/null +++ b/internal/modules/master/phase-activities/validations/phase-activity.validation.go @@ -0,0 +1,21 @@ +package validation + +type Create struct { + PhaseId uint `json:"phase_id" validate:"required"` + Name string `json:"name" validate:"required_strict,min=3"` + Description *string `json:"description,omitempty"` + TimeType string `json:"time_type" validate:"required"` +} + +type Update struct { + Name string `json:"name" validate:"required_strict,min=3"` + Description *string `json:"description,omitempty"` + TimeType string `json:"time_type" validate:"required"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + PhaseId *uint `query:"phase_id" validate:"omitempty"` +} diff --git a/internal/modules/master/phasess/services/phases.service.go b/internal/modules/master/phasess/services/phases.service.go index 863b369d..98e73bef 100644 --- a/internal/modules/master/phasess/services/phases.service.go +++ b/internal/modules/master/phasess/services/phases.service.go @@ -84,7 +84,7 @@ func (s *phasesService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity } if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { - return db.Where("LOWER(name) = ?", strings.ToLower(req.Name)) + return db.Where("LOWER(name) = ? AND category = ?", strings.ToLower(req.Name), req.Category) }); err == nil { return nil, fiber.NewError(fiber.StatusBadRequest, "phase already exists") } else if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -111,11 +111,20 @@ func (s phasesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, err } + existing, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Phases not found") + } + if err != nil { + s.Log.Errorf("Failed get phases by id: %+v", err) + return nil, err + } + updateBody := make(map[string]any) if req.Name != nil { if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { - return db.Where("LOWER(name) = ? AND id <> ?", strings.ToLower(*req.Name), id) + return db.Where("LOWER(name) = ? AND category = ? AND id <> ?", strings.ToLower(*req.Name), existing.Category, id) }); err == nil { return nil, fiber.NewError(fiber.StatusBadRequest, "phase already exists") } else if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -130,9 +139,6 @@ func (s phasesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Phases not found") - } s.Log.Errorf("Failed to update phases: %+v", err) return nil, err } diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index e0a7b246..f9bc7b13 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -16,13 +16,14 @@ import ( kandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs" locations "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations" nonstocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks" + phaseActivitys "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities" + phasess "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess" productcategories "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories" productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards" products "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products" suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" - phasess "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess" // MODULE IMPORTS ) @@ -46,6 +47,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida productionStandards.ProductionStandardModule{}, employeess.EmployeesModule{}, phasess.PhasesModule{}, + phaseActivitys.PhaseActivityModule{}, // MODULE REGISTRY } From 1bdaf63763d99c4fa16404f511a349e8647492d4 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 6 Jan 2026 12:02:19 +0700 Subject: [PATCH 27/29] feat(BE-281): adjustment sso redirect,adjustment response closing,adjustment uniformity --- internal/config/config.go | 2 + .../closings/dto/closingSapronak.dto.go | 19 +- .../closings/services/sapronak.service.go | 6 +- .../services/projectflock.service.go | 21 -- .../controllers/uniformity.controller.go | 6 +- .../uniformities/dto/uniformity.dto.go | 18 ++ .../services/uniformity.service.go | 26 ++- .../modules/sso/controllers/sso.controller.go | 203 +++++++++++++++++- 8 files changed, 267 insertions(+), 34 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5f76a9e0..8660704b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,7 @@ var ( SSOAuthorizeURL string SSOTokenURL string SSOGetMeURL string + SSOPortalURL string SSOClients map[string]SSOClientConfig SSOAccessCookieName string SSORefreshCookieName string @@ -131,6 +132,7 @@ func init() { SSOAuthorizeURL = viper.GetString("SSO_AUTHORIZE_URL") SSOTokenURL = viper.GetString("SSO_TOKEN_URL") SSOGetMeURL = viper.GetString("SSO_GETME_URL") + SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL")) SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access") SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 13044efd..768c727e 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -134,7 +134,14 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin report = &SapronakReportDTO{} } - filter := strings.ToUpper(strings.TrimSpace(flag)) + normalizeFlag := func(raw string) string { + normalized := strings.ToUpper(strings.TrimSpace(raw)) + if normalized == "PULLET" { + return "DOC" + } + return normalized + } + filter := normalizeFlag(flag) byFlag := map[string]**SapronakCategoryDTO{} if filter == "" || filter == "DOC" { @@ -149,10 +156,6 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin result.Pakan = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} byFlag["PAKAN"] = &result.Pakan } - if filter == "" || filter == "PULLET" { - result.Pullet = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} - byFlag["PULLET"] = &result.Pullet - } formatDate := func(t *time.Time) string { if t == nil { @@ -162,7 +165,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } for _, group := range report.Groups { - flagKey := strings.ToUpper(group.Flag) + flagKey := normalizeFlag(group.Flag) ptr := byFlag[flagKey] if ptr == nil || *ptr == nil { continue @@ -182,7 +185,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } for idx, item := range group.Items { - productKey := strings.ToUpper(group.Flag + "|" + item.ProductName) + productKey := strings.ToUpper(flagKey + "|" + item.ProductName) baseRow := SapronakCategoryRowDTO{ ID: idx + 1, Date: formatDate(item.Tanggal), @@ -246,7 +249,5 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin buildTotals(result.Doc, "TOTAL DOC") buildTotals(result.Ovk, "TOTAL OVK") buildTotals(result.Pakan, "TOTAL PAKAN") - buildTotals(result.Pullet, "TOTAL PULLET") - return result } diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 3c1843dd..b923db5d 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -359,7 +359,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if filterFlag == "" { return true } - return strings.ToUpper(f) == filterFlag + candidate := strings.ToUpper(f) + if filterFlag == "DOC" || filterFlag == "PULLET" { + return candidate == "DOC" || candidate == "PULLET" + } + return candidate == filterFlag } // For project flocks with category GROWING, pullet usage from chickin diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index ec887eea..5f643dee 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -517,27 +517,6 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u return total, nil } -// getProjectFlockClosingDate mengembalikan tanggal closing Project Flock jika sudah mencapai step SELESAI (Approved). -// func (s projectflockService) getProjectFlockClosingDate(ctx context.Context, projectFlockID uint) (*time.Time, error) { -// if projectFlockID == 0 || s.ApprovalSvc == nil { -// return nil, nil -// } - -// latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock, projectFlockID, nil) -// if err != nil { -// return nil, err -// } -// if latest == nil || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { -// return nil, nil -// } -// if latest.StepNumber != uint16(utils.ProjectFlockStepSelesai) { -// return nil, nil -// } - -// t := latest.ActionAt -// return &t, nil -// } - func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) (map[uint]int, error) { if len(projectIDs) == 0 { return map[uint]int{}, nil diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go index ce91c3af..e18e7dce 100644 --- a/internal/modules/production/uniformities/controllers/uniformity.controller.go +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -36,6 +36,10 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { if err != nil { return err } + documents, err := u.UniformityService.MapDocuments(c, result) + if err != nil { + return err + } return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ @@ -53,7 +57,7 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { "status": "Pengajuan", }, }, - Data: dto.ToUniformityListDTOsWithStandard(result, standards), + Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents), }) } diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go index 0c38d81b..af401a54 100644 --- a/internal/modules/production/uniformities/dto/uniformity.dto.go +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -54,6 +54,7 @@ type UniformityDetailDTO struct { Sampling UniformitySamplingDTO `json:"sampling"` Result UniformityResultDTO `json:"result"` Standard *UniformityStandardDTO `json:"standard"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` } @@ -63,6 +64,7 @@ type UniformityListDTO struct { LocationName string `json:"location_name"` FlockName string `json:"flock_name"` KandangName string `json:"kandang_name"` + FileName string `json:"file_name"` AppliedAt *time.Time `json:"applied_at"` Week int `json:"week"` Status string `json:"status"` @@ -115,12 +117,19 @@ func ToUniformityDetailDTO( info.FileURL = documentURL } + var latestApproval *approvalDTO.ApprovalRelationDTO + if entityData.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*entityData.LatestApproval) + latestApproval = &mapped + } + return UniformityDetailDTO{ Id: entityData.Id, InfoUmum: info, Sampling: toUniformitySamplingDTO(calc), Result: toUniformityResultDTO(calc), Standard: standard, + LatestApproval: latestApproval, UniformityDetails: toUniformityDetailItemsDTO(calc), } } @@ -163,9 +172,15 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor func ToUniformityListDTOsWithStandard( items []entity.ProjectFlockKandangUniformity, standards map[uint]service.UniformityStandard, + documentNames map[uint]string, ) []UniformityListDTO { result := ToUniformityListDTOs(items) if len(result) == 0 || len(standards) == 0 { + for i := range result { + if name, ok := documentNames[result[i].Id]; ok { + result[i].FileName = name + } + } return result } @@ -174,6 +189,9 @@ func ToUniformityListDTOsWithStandard( result[i].StandardMeanWeight = std.MeanWeight result[i].StandardUniformity = std.Uniformity } + if name, ok := documentNames[result[i].Id]; ok { + result[i].FileName = name + } } return result } diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index fb7ed9ed..747eb965 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -33,6 +33,7 @@ type UniformityService interface { GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) + MapDocuments(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) CreateOne(ctx *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) DeleteOne(ctx *fiber.Ctx, id uint) error @@ -189,6 +190,29 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc return result, nil } +func (s uniformityService) MapDocuments(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) { + if s.DocumentSvc == nil || len(items) == 0 { + return map[uint]string{}, nil + } + + result := make(map[uint]string, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(item.Id)) + if err != nil { + return nil, err + } + if len(documents) == 0 { + continue + } + result[item.Id] = documents[len(documents)-1].Name + } + + return result, nil +} + func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -649,7 +673,7 @@ func (s uniformityService) fetchUniformityDocument(ctx context.Context, uniformi return nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") } - document := documents[0] + document := documents[len(documents)-1] url, err := s.DocumentSvc.PresignURL(ctx, document, 15*time.Minute) if err != nil { return nil, "", err diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index 554b3388..410e9577 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -144,6 +144,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error { refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") refreshToken := strings.TrimSpace(c.Cookies(refreshName)) if refreshToken == "" { + if target := buildStartRedirect(defaultSSOClientAlias()); target != "" { + return c.Redirect(target, fiber.StatusFound) + } return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") } @@ -174,6 +177,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error { if resp.StatusCode == fiber.StatusTooManyRequests { return fiber.NewError(fiber.StatusTooManyRequests, "Too many attempts, please slow down") } + if target := buildStartRedirect(defaultSSOClientAlias()); target != "" { + return c.Redirect(target, fiber.StatusFound) + } return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") } @@ -425,6 +431,7 @@ func (h *Controller) Logout(c *fiber.Ctx) error { refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") var accessToken, refreshToken string + var verification *sso.VerificationResult if accessName != "" { accessToken = strings.TrimSpace(c.Cookies(accessName)) } @@ -446,9 +453,10 @@ func (h *Controller) Logout(c *fiber.Ctx) error { } if hadAccessCookie { - if verification, err := sso.VerifyAccessToken(accessToken); err != nil { + if v, err := sso.VerifyAccessToken(accessToken); err != nil { utils.Log.WithError(err).Warn("failed to verify access token during logout") } else { + verification = v if revoker := session.GetRevocationStore(); revoker != nil { if err := revoker.MarkUserLogout(c.Context(), verification.UserID, time.Now().UTC()); err != nil { utils.Log.WithError(err).Warn("failed to mark user logout") @@ -475,6 +483,28 @@ func (h *Controller) Logout(c *fiber.Ctx) error { } else if rawReturn != "" { utils.Log.WithError(err).Warn("invalid return_to during logout") } + } else if rawReturn == "" && config.SSOPortalURL != "" { + if alias, singleCfg, ok := singleClientFromToken(verification); ok { + if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" { + redirectTarget = normalized + alias, cfg, hasClientInfo = alias, singleCfg, true + } else { + redirectTarget = config.SSOPortalURL + } + } else if accessToken != "" { + if alias, singleCfg, ok := h.singleClientFromSSO(c.Context(), accessToken); ok { + if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" { + redirectTarget = normalized + alias, cfg, hasClientInfo = alias, singleCfg, true + } else { + redirectTarget = config.SSOPortalURL + } + } else { + redirectTarget = config.SSOPortalURL + } + } else { + redirectTarget = config.SSOPortalURL + } } else if rawReturn != "" { if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") { redirectTarget = rawReturn @@ -494,6 +524,177 @@ func (h *Controller) Logout(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"}) } +func singleSSOClient() (string, config.SSOClientConfig, bool) { + if len(config.SSOClients) != 1 { + return "", config.SSOClientConfig{}, false + } + for alias, cfg := range config.SSOClients { + if strings.TrimSpace(alias) == "" || strings.TrimSpace(cfg.PublicID) == "" { + return "", config.SSOClientConfig{}, false + } + return alias, cfg, true + } + return "", config.SSOClientConfig{}, false +} + +func singleClientFromToken(verification *sso.VerificationResult) (string, config.SSOClientConfig, bool) { + if verification == nil || verification.Claims == nil { + return "", config.SSOClientConfig{}, false + } + return singleClientFromScopes(verification.Claims.Scopes()) +} + +func (h *Controller) singleClientFromSSO(ctx context.Context, accessToken string) (string, config.SSOClientConfig, bool) { + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return "", config.SSOClientConfig{}, false + } + meURL := strings.TrimSpace(config.SSOGetMeURL) + if meURL == "" { + return "", config.SSOClientConfig{}, false + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) + if err != nil { + utils.Log.WithError(err).Warn("failed to build SSO getme request") + return "", config.SSOClientConfig{}, false + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := h.httpClient.Do(req) + if err != nil { + utils.Log.WithError(err).Warn("SSO getme request failed") + return "", config.SSOClientConfig{}, false + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + utils.Log.WithField("status", resp.StatusCode).Warn("SSO getme responded with error") + return "", config.SSOClientConfig{}, false + } + + var payload struct { + Data struct { + Roles []struct { + Client *struct { + Alias string `json:"alias"` + } `json:"client"` + } `json:"roles"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + utils.Log.WithError(err).Warn("failed to decode SSO getme response") + return "", config.SSOClientConfig{}, false + } + + aliases := make(map[string]struct{}) + for _, role := range payload.Data.Roles { + if role.Client == nil { + continue + } + alias := strings.ToLower(strings.TrimSpace(role.Client.Alias)) + if alias != "" { + aliases[alias] = struct{}{} + } + } + if len(aliases) != 1 { + return "", config.SSOClientConfig{}, false + } + for alias := range aliases { + if normalized, cfg, ok := findClientAlias(alias); ok { + return normalized, cfg, true + } + return "", config.SSOClientConfig{}, false + } + return "", config.SSOClientConfig{}, false +} + +func singleClientFromScopes(scopes []string) (string, config.SSOClientConfig, bool) { + if len(scopes) == 0 { + return "", config.SSOClientConfig{}, false + } + seen := make(map[string]struct{}) + for _, scope := range scopes { + if alias, ok := matchClientAliasFromScope(scope); ok { + seen[alias] = struct{}{} + } + if len(seen) > 1 { + return "", config.SSOClientConfig{}, false + } + } + if len(seen) != 1 { + return "", config.SSOClientConfig{}, false + } + for alias := range seen { + if normalized, cfg, ok := findClientAlias(alias); ok { + return normalized, cfg, true + } + } + return "", config.SSOClientConfig{}, false +} + +func matchClientAliasFromScope(scope string) (string, bool) { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + return "", false + } + prefix := scope + if idx := strings.IndexAny(prefix, ".:"); idx > 0 { + prefix = prefix[:idx] + } + if prefix == "" { + return "", false + } + if alias, _, ok := findClientAlias(prefix); ok { + return alias, true + } + if prefix == "user-management" { + if alias, _, ok := findClientAlias("umgmt"); ok { + return alias, true + } + } + if prefix == "umgmt" { + if alias, _, ok := findClientAlias("user-management"); ok { + return alias, true + } + } + return "", false +} + +func findClientAlias(alias string) (string, config.SSOClientConfig, bool) { + alias = strings.TrimSpace(alias) + if alias == "" { + return "", config.SSOClientConfig{}, false + } + if cfg, ok := config.SSOClients[alias]; ok && strings.TrimSpace(cfg.PublicID) != "" { + return alias, cfg, true + } + for key, cfg := range config.SSOClients { + if strings.EqualFold(key, alias) && strings.TrimSpace(cfg.PublicID) != "" { + return key, cfg, true + } + } + return "", config.SSOClientConfig{}, false +} + +func defaultSSOClientAlias() string { + for alias := range config.SSOClients { + if strings.TrimSpace(alias) == "" { + continue + } + return alias + } + return "" +} + +func buildStartRedirect(alias string) string { + alias = strings.TrimSpace(alias) + if alias == "" { + return "" + } + return "/api/sso/start?client=" + url.QueryEscape(alias) +} + func (h *Controller) revokeToken(ctx context.Context, token string, verification *sso.VerificationResult) { if h.revoker == nil || verification == nil || verification.Claims == nil { return From 7a26ca5fe5567eef9eab7785343878fd3a735ee7 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 6 Jan 2026 17:01:09 +0700 Subject: [PATCH 28/29] feat(BE-281): adjustment recording to cascade --- ...260106090725_fk_recording_cascade.down.sql | 21 +++++++++++++++++++ ...20260106090725_fk_recording_cascade.up.sql | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 internal/database/migrations/20260106090725_fk_recording_cascade.down.sql create mode 100644 internal/database/migrations/20260106090725_fk_recording_cascade.up.sql diff --git a/internal/database/migrations/20260106090725_fk_recording_cascade.down.sql b/internal/database/migrations/20260106090725_fk_recording_cascade.down.sql new file mode 100644 index 00000000..efe3954a --- /dev/null +++ b/internal/database/migrations/20260106090725_fk_recording_cascade.down.sql @@ -0,0 +1,21 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_recordings_project_flock_kandang' + ) THEN + ALTER TABLE recordings + DROP CONSTRAINT fk_recordings_project_flock_kandang; + END IF; +END $$; + +ALTER TABLE recordings + ADD CONSTRAINT fk_recordings_project_flock_kandang + FOREIGN KEY (project_flock_kandangs_id) + REFERENCES project_flock_kandangs (id) + ON DELETE RESTRICT ON UPDATE CASCADE; + +COMMIT; diff --git a/internal/database/migrations/20260106090725_fk_recording_cascade.up.sql b/internal/database/migrations/20260106090725_fk_recording_cascade.up.sql new file mode 100644 index 00000000..2600827d --- /dev/null +++ b/internal/database/migrations/20260106090725_fk_recording_cascade.up.sql @@ -0,0 +1,21 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_recordings_project_flock_kandang' + ) THEN + ALTER TABLE recordings + DROP CONSTRAINT fk_recordings_project_flock_kandang; + END IF; +END $$; + +ALTER TABLE recordings + ADD CONSTRAINT fk_recordings_project_flock_kandang + FOREIGN KEY (project_flock_kandangs_id) + REFERENCES project_flock_kandangs (id) + ON DELETE CASCADE ON UPDATE CASCADE; + +COMMIT; From 3bd0602525873b644355c5f1b13893e85e0505a2 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Tue, 6 Jan 2026 17:03:55 +0700 Subject: [PATCH 29/29] add daily checklist module;adjust master data;adjust migration --- ...01434_add_unique_daily_checklists.down.sql | 2 + ...6101434_add_unique_daily_checklists.up.sql | 3 + ..._checklists_checklist_id_nullable.down.sql | 2 + ...ly_checklists_checklist_id_nullable.up.sql | 2 + ..._update_daily_checklist_phases_fk.down.sql | 4 + ...17_update_daily_checklist_phases_fk.up.sql | 4 + ..._daily_checklist_activity_task_fk.down.sql | 4 + ...te_daily_checklist_activity_task_fk.up.sql | 4 + ..._unique_activity_task_assignments.down.sql | 2 + ...dd_unique_activity_task_assignments.up.sql | 3 + ...0_add_deleted_at_to_master_tables.down.sql | 8 + ...640_add_deleted_at_to_master_tables.up.sql | 8 + internal/entities/daily-checklist.go | 81 ++++ internal/entities/employee.go | 17 +- internal/entities/phase.go | 30 +- .../controllers/daily-checklist.controller.go | 243 +++++++++++ .../dto/daily-checklist.dto.go | 76 ++++ internal/modules/daily-checklists/module.go | 27 ++ .../daily-checklist.repository.go | 21 + internal/modules/daily-checklists/route.go | 35 ++ .../services/daily-checklist.service.go | 410 ++++++++++++++++++ .../validations/daily-checklist.validation.go | 26 ++ .../employees/services/employees.service.go | 45 +- .../validations/employees.validation.go | 4 +- internal/route/route.go | 2 + 25 files changed, 1011 insertions(+), 52 deletions(-) create mode 100644 internal/database/migrations/20260106101434_add_unique_daily_checklists.down.sql create mode 100644 internal/database/migrations/20260106101434_add_unique_daily_checklists.up.sql create mode 100644 internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.down.sql create mode 100644 internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.up.sql create mode 100644 internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.down.sql create mode 100644 internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.up.sql create mode 100644 internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.down.sql create mode 100644 internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.up.sql create mode 100644 internal/database/migrations/20260106150814_add_unique_activity_task_assignments.down.sql create mode 100644 internal/database/migrations/20260106150814_add_unique_activity_task_assignments.up.sql create mode 100644 internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.down.sql create mode 100644 internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.up.sql create mode 100644 internal/entities/daily-checklist.go create mode 100644 internal/modules/daily-checklists/controllers/daily-checklist.controller.go create mode 100644 internal/modules/daily-checklists/dto/daily-checklist.dto.go create mode 100644 internal/modules/daily-checklists/module.go create mode 100644 internal/modules/daily-checklists/repositories/daily-checklist.repository.go create mode 100644 internal/modules/daily-checklists/route.go create mode 100644 internal/modules/daily-checklists/services/daily-checklist.service.go create mode 100644 internal/modules/daily-checklists/validations/daily-checklist.validation.go diff --git a/internal/database/migrations/20260106101434_add_unique_daily_checklists.down.sql b/internal/database/migrations/20260106101434_add_unique_daily_checklists.down.sql new file mode 100644 index 00000000..f33ea629 --- /dev/null +++ b/internal/database/migrations/20260106101434_add_unique_daily_checklists.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE daily_checklists + DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key; diff --git a/internal/database/migrations/20260106101434_add_unique_daily_checklists.up.sql b/internal/database/migrations/20260106101434_add_unique_daily_checklists.up.sql new file mode 100644 index 00000000..6566083b --- /dev/null +++ b/internal/database/migrations/20260106101434_add_unique_daily_checklists.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE daily_checklists + ADD CONSTRAINT daily_checklists_date_kandang_category_key + UNIQUE (date, kandang_id, category); diff --git a/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.down.sql b/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.down.sql new file mode 100644 index 00000000..a1095689 --- /dev/null +++ b/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE daily_checklists + ALTER COLUMN checklist_id SET NOT NULL; diff --git a/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.up.sql b/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.up.sql new file mode 100644 index 00000000..2f804e4b --- /dev/null +++ b/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE daily_checklists + ALTER COLUMN checklist_id DROP NOT NULL; diff --git a/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.down.sql b/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.down.sql new file mode 100644 index 00000000..e2b34f4e --- /dev/null +++ b/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE daily_checklist_phases + DROP CONSTRAINT IF EXISTS fk_dcp_daily_checklist, + ADD CONSTRAINT fk_dcp_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) ON DELETE CASCADE; diff --git a/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.up.sql b/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.up.sql new file mode 100644 index 00000000..5f4384b4 --- /dev/null +++ b/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE daily_checklist_phases + DROP CONSTRAINT IF EXISTS fk_dcp_checklist, + ADD CONSTRAINT fk_dcp_daily_checklist + FOREIGN KEY (checklist_id) REFERENCES daily_checklists(id) ON DELETE CASCADE; diff --git a/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.down.sql b/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.down.sql new file mode 100644 index 00000000..e37f1ad0 --- /dev/null +++ b/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE daily_checklist_activity_tasks + DROP CONSTRAINT IF EXISTS fk_dcat_daily_checklist, + ADD CONSTRAINT fk_dcat_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) ON DELETE CASCADE; diff --git a/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.up.sql b/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.up.sql new file mode 100644 index 00000000..337ea821 --- /dev/null +++ b/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE daily_checklist_activity_tasks + DROP CONSTRAINT IF EXISTS fk_dcat_checklist, + ADD CONSTRAINT fk_dcat_daily_checklist + FOREIGN KEY (checklist_id) REFERENCES daily_checklists(id) ON DELETE CASCADE; diff --git a/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.down.sql b/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.down.sql new file mode 100644 index 00000000..921645e0 --- /dev/null +++ b/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE daily_checklist_activity_task_assignments + DROP CONSTRAINT IF EXISTS daily_checklist_activity_task_assignments_task_employee_key; diff --git a/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.up.sql b/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.up.sql new file mode 100644 index 00000000..b4fd9e18 --- /dev/null +++ b/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE daily_checklist_activity_task_assignments + ADD CONSTRAINT daily_checklist_activity_task_assignments_task_employee_key + UNIQUE (task_id, employee_id); diff --git a/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.down.sql b/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.down.sql new file mode 100644 index 00000000..fb17404d --- /dev/null +++ b/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE phase_activities + DROP COLUMN IF EXISTS deleted_at; + +ALTER TABLE phases + DROP COLUMN IF EXISTS deleted_at; + +ALTER TABLE employees + DROP COLUMN IF EXISTS deleted_at; diff --git a/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.up.sql b/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.up.sql new file mode 100644 index 00000000..0fdf6531 --- /dev/null +++ b/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE employees + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +ALTER TABLE phases + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +ALTER TABLE phase_activities + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; diff --git a/internal/entities/daily-checklist.go b/internal/entities/daily-checklist.go new file mode 100644 index 00000000..8b62b1a3 --- /dev/null +++ b/internal/entities/daily-checklist.go @@ -0,0 +1,81 @@ +package entities + +import "time" + +type DailyChecklist struct { + Id uint `gorm:"primaryKey"` + KandangId uint `gorm:"not null"` + ChecklistId *uint + Date time.Time `gorm:"type:date;not null"` + Name *string `gorm:"type:varchar(255)"` + Status *string `gorm:"type:varchar(255)"` + Category string `gorm:"type:category_code;not null"` + TotalScore *int + DocumentPath *string + RejectReason *string + CreatedBy *uint + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` + Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"` + Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` + Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` +} + +type DailyChecklistPhase struct { + Id uint `gorm:"primaryKey"` + ChecklistId uint `gorm:"not null"` + PhaseId uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + + Checklist Checklist `gorm:"foreignKey:ChecklistId;references:Id"` + Phase Phases `gorm:"foreignKey:PhaseId;references:Id"` +} + +type DailyChecklistActivityTask struct { + Id uint `gorm:"primaryKey"` + ChecklistId uint `gorm:"not null"` + PhaseId uint `gorm:"not null"` + PhaseActivityId uint `gorm:"not null"` + TimeType *string `gorm:"type:text"` + Notes *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Checklist DailyChecklist `gorm:"foreignKey:ChecklistId;references:Id"` + Phase Phases `gorm:"foreignKey:PhaseId;references:Id"` + PhaseActivity PhaseActivity `gorm:"foreignKey:PhaseActivityId;references:Id"` + Assignments []DailyChecklistActivityTaskAssignment `gorm:"foreignKey:TaskId;references:Id"` +} + +type DailyChecklistActivityTaskAssignment struct { + Id uint `gorm:"primaryKey"` + TaskId uint `gorm:"not null"` + EmployeeId uint `gorm:"not null"` + Checked bool `gorm:"not null;default:false"` + Note *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Task DailyChecklistActivityTask `gorm:"foreignKey:TaskId;references:Id"` + Employee Employee `gorm:"foreignKey:EmployeeId;references:Id"` +} + +type DailyChecklistTask struct { + Id uint `gorm:"primaryKey"` + DailyChecklistId uint `gorm:"not null"` + ChecklistId uint `gorm:"not null"` + ChecklistItemId *uint + IsCompleted bool `gorm:"not null;default:false"` + ScoreValue *int + Notes *string `gorm:"type:text"` + PhotoProof *string + Status *string + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + DailyChecklist *DailyChecklist `gorm:"foreignKey:DailyChecklistId;references:Id"` + Checklist Checklist `gorm:"foreignKey:ChecklistId;references:Id"` + ChecklistItem *PhaseActivity `gorm:"foreignKey:ChecklistItemId;references:Id"` +} diff --git a/internal/entities/employee.go b/internal/entities/employee.go index 5810c6ee..a93cbb46 100644 --- a/internal/entities/employee.go +++ b/internal/entities/employee.go @@ -1,13 +1,18 @@ package entities -import "time" +import ( + "time" + + "gorm.io/gorm" +) type Employee struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` - IsActive bool `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + IsActive bool `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` EmployeeKandangs []EmployeeKandang `gorm:"foreignKey:EmployeeId;references:Id"` } diff --git a/internal/entities/phase.go b/internal/entities/phase.go index d30369eb..178ed695 100644 --- a/internal/entities/phase.go +++ b/internal/entities/phase.go @@ -7,25 +7,27 @@ import ( ) type Phases struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` - IsActive bool `gorm:"not null;default:true"` - Category string `gorm:"type:category_code;not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + IsActive bool `gorm:"not null;default:true"` + Category string `gorm:"type:category_code;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Activities []PhaseActivity `gorm:"foreignKey:PhaseId;references:Id"` } type PhaseActivity struct { - Id uint `gorm:"primaryKey"` - PhaseId uint `gorm:"not null"` - Name string `gorm:"not null"` - Description *string `gorm:"type:text"` - TimeType *string `gorm:"type:text"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` + Id uint `gorm:"primaryKey"` + PhaseId uint `gorm:"not null"` + Name string `gorm:"not null"` + Description *string `gorm:"type:text"` + TimeType *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Phase Phase `gorm:"foreignKey:PhaseId;references:Id"` + Phase Phases `gorm:"foreignKey:PhaseId;references:Id"` } type Checklist struct { @@ -37,5 +39,5 @@ type Checklist struct { UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Phase *Phase `gorm:"foreignKey:PhaseId;references:Id"` + Phase *Phases `gorm:"foreignKey:PhaseId;references:Id"` } diff --git a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go new file mode 100644 index 00000000..b5a9b7b5 --- /dev/null +++ b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go @@ -0,0 +1,243 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type DailyChecklistController struct { + DailyChecklistService service.DailyChecklistService +} + +func NewDailyChecklistController(dailyChecklistService service.DailyChecklistService) *DailyChecklistController { + return &DailyChecklistController{ + DailyChecklistService: dailyChecklistService, + } +} + +func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + 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.DailyChecklistService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.DailyChecklistListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all dailyChecklists successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToDailyChecklistListDTOs(result), + }) +} + +func (u *DailyChecklistController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.DailyChecklistService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get dailyChecklist successfully", + Data: dto.ToDailyChecklistListDTO(*result), + }) +} + +func (u *DailyChecklistController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.DailyChecklistService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create dailyChecklist successfully", + Data: dto.ToDailyChecklistListDTO(*result), + }) +} + +func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.DailyChecklistService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update dailyChecklist successfully", + Data: dto.ToDailyChecklistListDTO(*result), + }) +} + +func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.DailyChecklistService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete dailyChecklist successfully", + }) +} + +func (u *DailyChecklistController) CreateDailyChecklistPhase(c *fiber.Ctx) error { + param := c.Params("idDailyChecklist") + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id") + } + + req := new(validation.AssignPhases) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if err := u.DailyChecklistService.AssignPhases(c, uint(id), req); err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Daily checklist phases saved successfully", + }) +} + +func (u *DailyChecklistController) CreateAssignment(c *fiber.Ctx) error { + param := c.Params("idDailyChecklist") + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id") + } + + req := new(validation.AssignTask) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if err := u.DailyChecklistService.AssignTasks(c, uint(id), req); err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Daily checklist assignments saved successfully", + }) +} + +func (u *DailyChecklistController) RemoveAssignment(c *fiber.Ctx) error { + dailyChecklistParam := c.Params("idDailyChecklist") + employeeParam := c.Params("idEmployee") + + dailyChecklistID, err := strconv.Atoi(dailyChecklistParam) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id") + } + + employeeID, err := strconv.Atoi(employeeParam) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id") + } + + if err := u.DailyChecklistService.RemoveAssignment(c, uint(dailyChecklistID), uint(employeeID)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Assignment removed successfully", + }) +} + +func (u *DailyChecklistController) GetAllTasks(c *fiber.Ctx) error { + checklistParam := c.Query("checklist_id", "") + if checklistParam == "" { + return fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") + } + + checklistID, err := strconv.Atoi(checklistParam) + if err != nil || checklistID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid checklist_id") + } + + result, err := u.DailyChecklistService.GetTasks(c, uint(checklistID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get daily checklist tasks successfully", + Data: result, + }) +} diff --git a/internal/modules/daily-checklists/dto/daily-checklist.dto.go b/internal/modules/daily-checklists/dto/daily-checklist.dto.go new file mode 100644 index 00000000..31953def --- /dev/null +++ b/internal/modules/daily-checklists/dto/daily-checklist.dto.go @@ -0,0 +1,76 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type DailyChecklistRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type DailyChecklistListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DailyChecklistDetailDTO struct { + DailyChecklistListDTO +} + +// === Mapper Functions === + +func ToDailyChecklistRelationDTO(e entity.DailyChecklist) DailyChecklistRelationDTO { + var name string + if e.Name != nil { + name = *e.Name + } + + return DailyChecklistRelationDTO{ + Id: e.Id, + Name: name, + } +} + +func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO { + var createdUser *userDTO.UserRelationDTO + // if e.CreatedUser.Id != 0 { + // mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + // createdUser = &mapped + // } + + var name string + if e.Name != nil { + name = *e.Name + } + + return DailyChecklistListDTO{ + Id: e.Id, + Name: name, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToDailyChecklistListDTOs(e []entity.DailyChecklist) []DailyChecklistListDTO { + result := make([]DailyChecklistListDTO, len(e)) + for i, r := range e { + result[i] = ToDailyChecklistListDTO(r) + } + return result +} + +func ToDailyChecklistDetailDTO(e entity.DailyChecklist) DailyChecklistDetailDTO { + return DailyChecklistDetailDTO{ + DailyChecklistListDTO: ToDailyChecklistListDTO(e), + } +} diff --git a/internal/modules/daily-checklists/module.go b/internal/modules/daily-checklists/module.go new file mode 100644 index 00000000..bc82d5f6 --- /dev/null +++ b/internal/modules/daily-checklists/module.go @@ -0,0 +1,27 @@ +package dailyChecklists + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rDailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" + sDailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" + rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type DailyChecklistModule struct{} + +func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db) + phasesRepo := rPhases.NewPhasesRepository(db) + userRepo := rUser.NewUserRepository(db) + + dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + DailyChecklistRoutes(router, userService, dailyChecklistService) +} diff --git a/internal/modules/daily-checklists/repositories/daily-checklist.repository.go b/internal/modules/daily-checklists/repositories/daily-checklist.repository.go new file mode 100644 index 00000000..e653ba3b --- /dev/null +++ b/internal/modules/daily-checklists/repositories/daily-checklist.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type DailyChecklistRepository interface { + repository.BaseRepository[entity.DailyChecklist] +} + +type DailyChecklistRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.DailyChecklist] +} + +func NewDailyChecklistRepository(db *gorm.DB) DailyChecklistRepository { + return &DailyChecklistRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklist](db), + } +} diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go new file mode 100644 index 00000000..c8542671 --- /dev/null +++ b/internal/modules/daily-checklists/route.go @@ -0,0 +1,35 @@ +package dailyChecklists + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/controllers" + dailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist.DailyChecklistService) { + ctrl := controller.NewDailyChecklistController(s) + + route := v1.Group("/daily-checklists") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + + // create task + route.Post("/phase/:idDailyChecklist", ctrl.CreateDailyChecklistPhase) + + // create assigment + route.Post("/assignment/:idDailyChecklist", ctrl.CreateAssignment) + // remove assignment + route.Delete("/:idDailyChecklist/assignments/:idEmployee", ctrl.RemoveAssignment) + + //get all tasks + route.Get("/tasks", ctrl.GetAllTasks) + + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go new file mode 100644 index 00000000..bf5320e6 --- /dev/null +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -0,0 +1,410 @@ +package service + +import ( + "errors" + "strconv" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" + phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type DailyChecklistService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.DailyChecklist, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error + AssignTasks(ctx *fiber.Ctx, id uint, req *validation.AssignTask) error + RemoveAssignment(ctx *fiber.Ctx, id uint, employeeID uint) error + GetTasks(ctx *fiber.Ctx, checklistID uint) ([]entity.DailyChecklistActivityTask, error) +} + +type dailyChecklistService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.DailyChecklistRepository + PhaseRepo phaseRepo.PhasesRepository +} + +func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate) DailyChecklistService { + return &dailyChecklistService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + PhaseRepo: phaseRepo, + } +} + +func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB { + return db +} + +func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.DailyChecklist, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + dailyChecklists, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get dailyChecklists: %+v", err) + return nil, 0, err + } + return dailyChecklists, total, nil +} + +func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) { + dailyChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + if err != nil { + s.Log.Errorf("Failed get dailyChecklist by id: %+v", err) + return nil, err + } + return dailyChecklist, nil +} + +func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + date, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") + } + + status := req.Status + category := req.Category + + createBody := &entity.DailyChecklist{ + KandangId: req.KandangId, + Date: date, + Category: category, + Status: &status, + } + + err = s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "date"}, {Name: "kandang_id"}, {Name: "category"}}, + DoUpdates: clause.Assignments(map[string]any{"status": status, "updated_at": time.Now()}), + }).Create(createBody).Error + if err != nil { + s.Log.Errorf("Failed to upsert dailyChecklist: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + updateBody["name"] = *req.Name + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + s.Log.Errorf("Failed to update dailyChecklist: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + s.Log.Errorf("Failed to delete dailyChecklist: %+v", err) + return err + } + return nil +} + +func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validation.AssignPhases) error { + if err := s.Validate.Struct(req); err != nil { + return err + } + + if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return err + } + + phaseIDs, err := parsePhaseIDs(req.PhaseIDs) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if len(phaseIDs) > 0 { + phases, err := s.PhaseRepo.GetByIDs(c.Context(), phaseIDs, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, "Phase not found") + } + return err + } + if len(phases) != len(phaseIDs) { + return fiber.NewError(fiber.StatusBadRequest, "Phase not found") + } + } + + db := s.Repository.DB() + if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistPhase{}).Error; err != nil { + return err + } + + if len(phaseIDs) == 0 { + return nil + } + + records := make([]entity.DailyChecklistPhase, 0, len(phaseIDs)) + for _, pid := range phaseIDs { + records = append(records, entity.DailyChecklistPhase{ + ChecklistId: id, + PhaseId: pid, + }) + } + + if err := tx.Create(&records).Error; err != nil { + return err + } + + if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistActivityTask{}).Error; err != nil { + return err + } + + var activities []entity.PhaseActivity + if err := tx.Where("phase_id IN ?", phaseIDs).Find(&activities).Error; err != nil { + return err + } + + activityRecords := make([]entity.DailyChecklistActivityTask, 0, len(activities)) + for _, activity := range activities { + activityRecords = append(activityRecords, entity.DailyChecklistActivityTask{ + ChecklistId: id, + PhaseId: activity.PhaseId, + PhaseActivityId: activity.Id, + TimeType: activity.TimeType, + }) + } + + if len(activityRecords) == 0 { + return nil + } + + return tx.Create(&activityRecords).Error + }); err != nil { + s.Log.Errorf("Failed to assign phases to daily checklist: %+v", err) + return err + } + + return nil +} + +func (s dailyChecklistService) RemoveAssignment(c *fiber.Ctx, id uint, employeeID uint) error { + if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return err + } + + if employeeID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id") + } + + db := s.Repository.DB() + if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + var tasks []entity.DailyChecklistActivityTask + if err := tx.Where("checklist_id = ?", id).Find(&tasks).Error; err != nil { + return err + } + + if len(tasks) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "No activity tasks found for this checklist") + } + + taskIDs := collectTaskIDs(tasks) + return tx.Where("task_id IN ? AND employee_id = ?", taskIDs, employeeID). + Delete(&entity.DailyChecklistActivityTaskAssignment{}).Error + }); err != nil { + s.Log.Errorf("Failed to remove assignment: %+v", err) + return err + } + + return nil +} + +func (s dailyChecklistService) GetTasks(c *fiber.Ctx, checklistID uint) ([]entity.DailyChecklistActivityTask, error) { + if checklistID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") + } + + if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return nil, err + } + + var tasks []entity.DailyChecklistActivityTask + if err := s.Repository.DB().WithContext(c.Context()). + Where("checklist_id = ?", checklistID). + Order("created_at ASC"). + Find(&tasks).Error; err != nil { + s.Log.Errorf("Failed to get daily checklist tasks: %+v", err) + return nil, err + } + + return tasks, nil +} + +func parsePhaseIDs(raw string) ([]uint, error) { + parts := strings.Split(raw, ",") + result := make([]uint, 0, len(parts)) + seen := make(map[uint]struct{}) + + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + + num, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return nil, errors.New("invalid phase id: " + value) + } + u := uint(num) + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + result = append(result, u) + } + + return result, nil +} + +func parseIDs(raw string) ([]uint, error) { + parts := strings.Split(raw, ",") + result := make([]uint, 0, len(parts)) + seen := make(map[uint]struct{}) + + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + + num, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return nil, errors.New("invalid employee id: " + value) + } + u := uint(num) + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + result = append(result, u) + } + + return result, nil +} + +func collectTaskIDs(tasks []entity.DailyChecklistActivityTask) []uint { + result := make([]uint, len(tasks)) + for i, task := range tasks { + result[i] = task.Id + } + return result +} +func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validation.AssignTask) error { + if err := s.Validate.Struct(req); err != nil { + return err + } + + employeeIDs, err := parseIDs(req.EmployeeIDs) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if len(employeeIDs) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "employee_ids cannot be empty") + } + + if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return err + } + + db := s.Repository.DB() + if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + var tasks []entity.DailyChecklistActivityTask + if err := tx.Where("checklist_id = ?", id).Find(&tasks).Error; err != nil { + return err + } + + if len(tasks) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "No activity tasks found for this checklist") + } + + assignments := make([]entity.DailyChecklistActivityTaskAssignment, 0, len(tasks)*len(employeeIDs)) + for _, task := range tasks { + for _, empID := range employeeIDs { + assignments = append(assignments, entity.DailyChecklistActivityTaskAssignment{ + TaskId: task.Id, + EmployeeId: empID, + }) + } + } + + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "task_id"}, {Name: "employee_id"}}, + DoUpdates: clause.Assignments(map[string]any{"updated_at": time.Now()}), + }).Create(&assignments).Error + }); err != nil { + s.Log.Errorf("Failed to assign tasks to daily checklist: %+v", err) + return err + } + + return nil +} diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go new file mode 100644 index 00000000..ba81fd0d --- /dev/null +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -0,0 +1,26 @@ +package validation + +type Create struct { + Date string `json:"date" validate:"required"` + KandangId uint `json:"kandang_id" validate:"required"` + Category string `json:"category" validate:"required"` + Status string `json:"status" validate:"required"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} + +type AssignPhases struct { + PhaseIDs string `json:"phase_ids" validate:"required"` +} + +type AssignTask struct { + EmployeeIDs string `json:"employee_ids" validate:"required"` +} diff --git a/internal/modules/master/employees/services/employees.service.go b/internal/modules/master/employees/services/employees.service.go index aa82255d..4998eaec 100644 --- a/internal/modules/master/employees/services/employees.service.go +++ b/internal/modules/master/employees/services/employees.service.go @@ -2,8 +2,6 @@ package service import ( "errors" - "fmt" - "strconv" "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -41,10 +39,8 @@ func NewEmployeesService(repo repository.EmployeesRepository, validate *validato func (s employeesService) withRelations(db *gorm.DB) *gorm.DB { return db. - Preload("EmployeeKandangs.Kandang") - // Preload("EmployeeKandangs.Kandang.Location"). - // Preload("EmployeeKandangs.Kandang.Pic"). - // Preload("EmployeeKandangs.Kandang.CreatedUser") + Preload("EmployeeKandangs.Kandang"). + Where("employees.deleted_at IS NULL") } func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Employees, int64, error) { @@ -98,9 +94,9 @@ func (s *employeesService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty") } - kandangIDs, err := parseKandangIDs(req.KandangIDs) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + kandangIDs := normalizeKandangIDs(req.KandangIDs) + if len(kandangIDs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id") } if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { @@ -181,9 +177,9 @@ func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if req.KandangIDs != nil { - ids, err := parseKandangIDs(*req.KandangIDs) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + ids := normalizeKandangIDs(*req.KandangIDs) + if len(ids) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id") } kandangIDs = ids @@ -248,33 +244,22 @@ func (s employeesService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func parseKandangIDs(raw string) ([]uint, error) { - parts := strings.Split(raw, ",") - ids := make([]uint, 0, len(parts)) +func normalizeKandangIDs(ids []uint) []uint { + result := make([]uint, 0, len(ids)) seen := make(map[uint]struct{}) - for _, part := range parts { - value := strings.TrimSpace(part) - if value == "" { + for _, id := range ids { + if id == 0 { continue } - parsed, err := strconv.ParseUint(value, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid kandang id: %s", value) - } - - id := uint(parsed) if _, ok := seen[id]; ok { continue } + seen[id] = struct{}{} - ids = append(ids, id) + result = append(result, id) } - if len(ids) == 0 { - return nil, errors.New("kandang_ids must contain at least one valid id") - } - - return ids, nil + return result } diff --git a/internal/modules/master/employees/validations/employees.validation.go b/internal/modules/master/employees/validations/employees.validation.go index 159b875f..2e2cc879 100644 --- a/internal/modules/master/employees/validations/employees.validation.go +++ b/internal/modules/master/employees/validations/employees.validation.go @@ -2,13 +2,13 @@ package validation type Create struct { Name string `json:"name" validate:"required_strict,min=3"` - KandangIDs string `json:"kandang_ids" validate:"required"` + KandangIDs []uint `json:"kandang_ids" validate:"required,min=1,dive,required"` IsActive bool `json:"is_active"` } type Update struct { Name *string `json:"name,omitempty" validate:"omitempty"` - KandangIDs *string `json:"kandang_ids,omitempty"` + KandangIDs *[]uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,required"` IsActive *bool `json:"is_active,omitempty"` } diff --git a/internal/route/route.go b/internal/route/route.go index 877ec875..519ea5aa 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -11,6 +11,7 @@ import ( approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals" closings "gitlab.com/mbugroup/lti-api.git/internal/modules/closings" constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" + dailyChecklists "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists" expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses" finance "gitlab.com/mbugroup/lti-api.git/internal/modules/finance" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" @@ -46,6 +47,7 @@ func Routes(app *fiber.App, db *gorm.DB) { closings.ClosingModule{}, repports.RepportModule{}, finance.FinanceModule{}, + dailyChecklists.DailyChecklistModule{}, // MODULE REGISTRY }