diff --git a/internal/database/migrations/20260428000001_create_system_settings.down.sql b/internal/database/migrations/20260428000001_create_system_settings.down.sql new file mode 100644 index 00000000..1e2a497b --- /dev/null +++ b/internal/database/migrations/20260428000001_create_system_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS system_settings; diff --git a/internal/database/migrations/20260428000001_create_system_settings.up.sql b/internal/database/migrations/20260428000001_create_system_settings.up.sql new file mode 100644 index 00000000..ecc7ca2f --- /dev/null +++ b/internal/database/migrations/20260428000001_create_system_settings.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE system_settings ( + key VARCHAR(100) PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO system_settings (key, value, description) VALUES + ('allow_negative_pakan_ovk', 'false', + 'Izinkan pencatatan penggunaan PAKAN & OVK negatif (mode migrasi): membuka semua produk PAKAN & OVK meskipun belum ada pembelian di sistem'); diff --git a/internal/entities/system_setting.go b/internal/entities/system_setting.go new file mode 100644 index 00000000..ee5beaff --- /dev/null +++ b/internal/entities/system_setting.go @@ -0,0 +1,17 @@ +package entities + +import "time" + +const SystemSettingKeyAllowNegativePakanOVK = "allow_negative_pakan_ovk" + +type SystemSetting struct { + Key string `gorm:"column:key;primaryKey"` + Value string `gorm:"column:value;not null;default:''"` + Description string `gorm:"column:description"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` +} + +func (SystemSetting) TableName() string { + return "system_settings" +} diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index f9d23d3e..d2381b78 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -4,6 +4,10 @@ const ( P_DashboardGetAll = "lti.dashboard.list" ) +const ( + P_SystemSettingUpdate = "lti.system_settings.update" +) + // project-flock const ( P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index 0c49a4ef..0a70ebb4 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -264,6 +264,18 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity db = db.Where("NOT "+existsQuery, entity.FlagableTypeProduct, depletionProductFlags) } } + if strings.TrimSpace(params.Flags) != "" { + cleanFlags := utils.ParseFlags(params.Flags) + if len(cleanFlags) > 0 { + db = db.Where(` + EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = ? + AND f.flagable_id = products.id + AND UPPER(f.name) IN ? + )`, entity.FlagableTypeProduct, cleanFlags) + } + } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/products/validations/product.validation.go b/internal/modules/master/products/validations/product.validation.go index c9068e6e..b6724716 100644 --- a/internal/modules/master/products/validations/product.validation.go +++ b/internal/modules/master/products/validations/product.validation.go @@ -46,4 +46,5 @@ type Query struct { ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"` IsDepletion *bool `query:"is_depletion" validate:"omitempty"` IncludeAll *bool `query:"include_all" validate:"omitempty"` + Flags string `query:"flags" validate:"omitempty,max=200"` } diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index adbf6a40..f1b61ad7 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -27,6 +27,7 @@ import ( rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + rSystemSettings "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -159,6 +160,8 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate validate, ) + systemSettingRepo := rSystemSettings.NewSystemSettingRepository(db) + recordingService := sRecording.NewRecordingService( recordingRepo, projectFlockKandangRepo, @@ -174,6 +177,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate transferLayingRepo, transferLayingService, validate, + systemSettingRepo, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 55315a16..4efa6b06 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -24,6 +24,7 @@ import ( rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + rSystemSettings "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" fifo "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -63,6 +64,7 @@ type recordingService struct { TransferLayingSvc sTransferLaying.TransferLayingService FifoStockV2Svc commonSvc.FifoStockV2Service StockLogRepo rStockLogs.StockLogRepository + SystemSettingRepo rSystemSettings.SystemSettingRepository } func NewRecordingService( @@ -80,6 +82,7 @@ func NewRecordingService( transferLayingRepo rTransferLaying.TransferLayingRepository, transferLayingSvc sTransferLaying.TransferLayingService, validate *validator.Validate, + systemSettingRepo rSystemSettings.SystemSettingRepository, ) RecordingService { return &recordingService{ Log: utils.Log, @@ -97,6 +100,7 @@ func NewRecordingService( TransferLayingSvc: transferLayingSvc, FifoStockV2Svc: fifoStockV2Svc, StockLogRepo: stockLogRepo, + SystemSettingRepo: systemSettingRepo, } } @@ -390,6 +394,11 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } + req.Stocks, err = s.resolveStocksForMigrationMode(ctx, req.Stocks, pfk, actorID) + if err != nil { + return nil, err + } + if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err } @@ -635,6 +644,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if match { hasStockChanges = false } else { + req.Stocks, err = s.resolveStocksForMigrationMode(ctx, req.Stocks, pfkForRoute, actorID) + if err != nil { + return err + } if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { return err } @@ -2205,6 +2218,104 @@ func (s *recordingService) validateWarehouseIDs( return nil } +// resolveMigrationWarehouseID returns the warehouse ID to use when auto-creating product_warehouse +// entries in migration mode. Prefers the farm-level (LOKASI) warehouse of the kandang's location; +// falls back to the kandang-level (KANDANG) warehouse if no LOKASI warehouse exists. +func (s *recordingService) resolveMigrationWarehouseID(ctx context.Context, kandangID uint) (uint, error) { + type row struct { + ID uint `gorm:"column:id"` + LocationID *uint `gorm:"column:location_id"` + } + + db := s.ProductWarehouseRepo.DB().WithContext(ctx) + + // Step 1: get the kandang's location_id + var kandang row + if err := db.Table("kandangs").Select("id, location_id"). + Where("id = ? AND deleted_at IS NULL", kandangID). + Limit(1).Take(&kandang).Error; err != nil { + return 0, fmt.Errorf("kandang %d tidak ditemukan: %w", kandangID, err) + } + + // Step 2: prefer a LOKASI-type warehouse at the kandang's location (farm-level) + if kandang.LocationID != nil && *kandang.LocationID != 0 { + var lokasi row + err := db.Table("warehouses").Select("id"). + Where("type = 'LOKASI' AND location_id = ? AND deleted_at IS NULL", *kandang.LocationID). + Limit(1).Take(&lokasi).Error + if err == nil { + return lokasi.ID, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fmt.Errorf("gagal mencari warehouse LOKASI untuk location %d: %w", *kandang.LocationID, err) + } + } + + // Step 3: fall back to the KANDANG-type warehouse + var kandangWH row + if err := db.Table("warehouses").Select("id"). + Where("type = 'KANDANG' AND kandang_id = ? AND deleted_at IS NULL", kandangID). + Limit(1).Take(&kandangWH).Error; err != nil { + return 0, fmt.Errorf("warehouse tidak ditemukan untuk kandang %d: %w", kandangID, err) + } + return kandangWH.ID, nil +} + +// resolveStocksForMigrationMode handles stocks that use product_id (instead of product_warehouse_id) +// when migration mode (allow_negative_pakan_ovk) is enabled. It finds or creates product_warehouse +// entries in the farm-level (LOKASI) warehouse, falling back to the kandang warehouse, then sets +// the resolved product_warehouse_id on each stock item. +func (s *recordingService) resolveStocksForMigrationMode( + ctx context.Context, + stocks []validation.Stock, + pfk *entity.ProjectFlockKandang, + actorID uint, +) ([]validation.Stock, error) { + if s.SystemSettingRepo == nil { + return stocks, nil + } + allowed, err := s.SystemSettingRepo.GetAllowNegativePakanOVK(ctx) + if err != nil { + s.Log.Errorf("Failed to read allow_negative_pakan_ovk setting: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membaca konfigurasi sistem") + } + if !allowed { + return stocks, nil + } + + var warehouseID uint + warehouseResolved := false + + result := make([]validation.Stock, len(stocks)) + copy(result, stocks) + + for i := range result { + stock := &result[i] + if stock.ProductId == nil || stock.ProductWarehouseId != 0 { + continue + } + // Resolve target warehouse lazily on first need (same for all stocks in one request) + if !warehouseResolved { + if pfk == nil || pfk.KandangId == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Kandang tidak ditemukan untuk mode migrasi") + } + warehouseID, err = s.resolveMigrationWarehouseID(ctx, pfk.KandangId) + if err != nil { + s.Log.Errorf("Failed to resolve migration warehouse for kandang %d: %+v", pfk.KandangId, err) + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse tidak ditemukan untuk mode migrasi") + } + warehouseResolved = true + } + pwID, err := s.ProductWarehouseRepo.EnsureProductWarehouse(ctx, *stock.ProductId, warehouseID, nil, actorID) + if err != nil { + s.Log.Errorf("Failed to ensure product warehouse for product %d in warehouse %d: %+v", *stock.ProductId, warehouseID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menyiapkan product warehouse untuk produk %d", *stock.ProductId)) + } + stock.ProductWarehouseId = pwID + } + return result, nil +} + func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlockKandangID uint, recordTime time.Time) (int, error) { if projectFlockKandangID == 0 { return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 73d4fdef..44a34cfa 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -4,7 +4,8 @@ import "time" type ( Stock struct { - ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + ProductWarehouseId uint `json:"product_warehouse_id" validate:"omitempty,number,min=1"` + ProductId *uint `json:"product_id,omitempty" validate:"omitempty,number,min=1"` Qty float64 `json:"qty" validate:"required,gte=0"` } diff --git a/internal/modules/system-settings/controllers/system_setting.controller.go b/internal/modules/system-settings/controllers/system_setting.controller.go new file mode 100644 index 00000000..bbae0c3c --- /dev/null +++ b/internal/modules/system-settings/controllers/system_setting.controller.go @@ -0,0 +1,40 @@ +package controller + +import ( + "github.com/gofiber/fiber/v2" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/services" +) + +type SystemSettingController struct { + Service service.SystemSettingService +} + +func NewSystemSettingController(svc service.SystemSettingService) *SystemSettingController { + return &SystemSettingController{Service: svc} +} + +func (ctrl *SystemSettingController) GetAll(c *fiber.Ctx) error { + settings, err := ctrl.Service.GetAll(c.Context()) + if err != nil { + return err + } + return c.Status(fiber.StatusOK).JSON(fiber.Map{"data": settings}) +} + +type setAllowNegativePakanOVKRequest struct { + Value bool `json:"value"` +} + +func (ctrl *SystemSettingController) SetAllowNegativePakanOVK(c *fiber.Ctx) error { + var req setAllowNegativePakanOVKRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Request body tidak valid") + } + if err := ctrl.Service.SetAllowNegativePakanOVK(c.Context(), req.Value); err != nil { + return err + } + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "message": "Setting berhasil diperbarui", + "value": req.Value, + }) +} diff --git a/internal/modules/system-settings/module.go b/internal/modules/system-settings/module.go new file mode 100644 index 00000000..6ad119b1 --- /dev/null +++ b/internal/modules/system-settings/module.go @@ -0,0 +1,18 @@ +package systemsettings + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/repositories" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/services" +) + +type SystemSettingsModule struct{} + +func (SystemSettingsModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + repo := repository.NewSystemSettingRepository(db) + svc := service.NewSystemSettingService(repo) + SystemSettingRoutes(router, svc) +} diff --git a/internal/modules/system-settings/repositories/system_setting.repository.go b/internal/modules/system-settings/repositories/system_setting.repository.go new file mode 100644 index 00000000..9f7d125d --- /dev/null +++ b/internal/modules/system-settings/repositories/system_setting.repository.go @@ -0,0 +1,62 @@ +package repository + +import ( + "context" + "errors" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type SystemSettingRepository interface { + Get(ctx context.Context, key string) (*entity.SystemSetting, error) + Set(ctx context.Context, key, value string) error + List(ctx context.Context) ([]entity.SystemSetting, error) + GetAllowNegativePakanOVK(ctx context.Context) (bool, error) +} + +type systemSettingRepositoryImpl struct { + db *gorm.DB +} + +func NewSystemSettingRepository(db *gorm.DB) SystemSettingRepository { + return &systemSettingRepositoryImpl{db: db} +} + +func (r *systemSettingRepositoryImpl) Get(ctx context.Context, key string) (*entity.SystemSetting, error) { + var setting entity.SystemSetting + if err := r.db.WithContext(ctx).Where("key = ?", key).First(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +func (r *systemSettingRepositoryImpl) Set(ctx context.Context, key, value string) error { + return r.db.WithContext(ctx). + Model(&entity.SystemSetting{}). + Where("key = ?", key). + Updates(map[string]interface{}{ + "value": value, + "updated_at": time.Now(), + }).Error +} + +func (r *systemSettingRepositoryImpl) List(ctx context.Context) ([]entity.SystemSetting, error) { + var settings []entity.SystemSetting + if err := r.db.WithContext(ctx).Order("key ASC").Find(&settings).Error; err != nil { + return nil, err + } + return settings, nil +} + +func (r *systemSettingRepositoryImpl) GetAllowNegativePakanOVK(ctx context.Context) (bool, error) { + setting, err := r.Get(ctx, entity.SystemSettingKeyAllowNegativePakanOVK) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return setting.Value == "true", nil +} diff --git a/internal/modules/system-settings/route.go b/internal/modules/system-settings/route.go new file mode 100644 index 00000000..d5f2c154 --- /dev/null +++ b/internal/modules/system-settings/route.go @@ -0,0 +1,16 @@ +package systemsettings + +import ( + "github.com/gofiber/fiber/v2" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/controllers" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/services" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" +) + +func SystemSettingRoutes(v1 fiber.Router, svc service.SystemSettingService) { + ctrl := controller.NewSystemSettingController(svc) + + route := v1.Group("/system-settings") + route.Get("/", ctrl.GetAll) + route.Patch("/allow-negative-pakan-ovk", m.RequirePermissions(m.P_SystemSettingUpdate), ctrl.SetAllowNegativePakanOVK) +} diff --git a/internal/modules/system-settings/services/system_setting.service.go b/internal/modules/system-settings/services/system_setting.service.go new file mode 100644 index 00000000..bfb90f4a --- /dev/null +++ b/internal/modules/system-settings/services/system_setting.service.go @@ -0,0 +1,46 @@ +package service + +import ( + "context" + + "github.com/gofiber/fiber/v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/repositories" +) + +type SystemSettingService interface { + GetAll(ctx context.Context) ([]entity.SystemSetting, error) + GetAllowNegativePakanOVK(ctx context.Context) (bool, error) + SetAllowNegativePakanOVK(ctx context.Context, allow bool) error +} + +type systemSettingService struct { + Repository repository.SystemSettingRepository +} + +func NewSystemSettingService(repo repository.SystemSettingRepository) SystemSettingService { + return &systemSettingService{Repository: repo} +} + +func (s *systemSettingService) GetAll(ctx context.Context) ([]entity.SystemSetting, error) { + settings, err := s.Repository.List(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil system settings") + } + return settings, nil +} + +func (s *systemSettingService) GetAllowNegativePakanOVK(ctx context.Context) (bool, error) { + return s.Repository.GetAllowNegativePakanOVK(ctx) +} + +func (s *systemSettingService) SetAllowNegativePakanOVK(ctx context.Context, allow bool) error { + value := "false" + if allow { + value = "true" + } + if err := s.Repository.Set(ctx, entity.SystemSettingKeyAllowNegativePakanOVK, value); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengubah setting allow_negative_pakan_ovk") + } + return nil +} diff --git a/internal/route/route.go b/internal/route/route.go index 71682d2b..5a9317ad 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -23,6 +23,7 @@ import ( ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" dashboards "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards" + systemsettings "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings" // MODULE IMPORTS ) @@ -50,6 +51,7 @@ func Routes(app *fiber.App, db *gorm.DB) { finance.FinanceModule{}, dailyChecklists.DailyChecklistModule{}, dashboards.DashboardModule{}, + systemsettings.SystemSettingsModule{}, // MODULE REGISTRY }