diff --git a/Makefile b/Makefile index a18b33ec..5533dc7f 100644 --- a/Makefile +++ b/Makefile @@ -57,7 +57,7 @@ wait-db: # Contoh: make migration-create_users_table # ":" akan diubah ke "_" (biar aman untuk nama file) migration-%: - @migrate create -ext sql -dir internal/database/migrations $(subst :,_,$*) + @migrate create -ext sql -dir $(MIGRATIONS_DIR) $(subst :,_,$*) # --- Migration (apply via docker image 'migrate') --- migrate-up: db-up wait-db diff --git a/go.mod b/go.mod index a4ad7610..3d7b91ba 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( github.com/bytedance/sonic v1.12.1 + github.com/glebarez/sqlite v1.11.0 github.com/go-playground/validator/v10 v10.27.0 github.com/gofiber/contrib/jwt v1.0.10 github.com/gofiber/fiber/v2 v2.52.5 @@ -28,7 +29,6 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -47,7 +47,6 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.1.2 // indirect @@ -76,7 +75,6 @@ require ( golang.org/x/text v0.22.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/sqlite v1.5.5 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 62bd157a..448287fc 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -69,8 +71,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -88,8 +90,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= -github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= @@ -211,8 +211,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= -gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= -gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= diff --git a/internal/database/migrations/20251015063107_add_status_kandangs_table.down.sql b/internal/database/migrations/20251015063107_add_status_kandangs_table.down.sql new file mode 100644 index 00000000..bfd2f5dc --- /dev/null +++ b/internal/database/migrations/20251015063107_add_status_kandangs_table.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE kandangs + DROP COLUMN IF EXISTS status; diff --git a/internal/database/migrations/20251015063107_add_status_kandangs_table.up.sql b/internal/database/migrations/20251015063107_add_status_kandangs_table.up.sql new file mode 100644 index 00000000..87c3ab62 --- /dev/null +++ b/internal/database/migrations/20251015063107_add_status_kandangs_table.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE kandangs + ADD COLUMN status VARCHAR(20); + +UPDATE kandangs +SET status = 'NON_ACTIVE' +WHERE status IS NULL; + +ALTER TABLE kandangs + ALTER COLUMN status SET NOT NULL; \ No newline at end of file diff --git a/internal/database/migrations/20251015065815_add_flocs_table.down.sql b/internal/database/migrations/20251015065815_add_flocs_table.down.sql new file mode 100644 index 00000000..8a9ee93e --- /dev/null +++ b/internal/database/migrations/20251015065815_add_flocs_table.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE kandangs + DROP COLUMN IF EXISTS project_flock_id; + +DROP TABLE IF EXISTS project_flocks; + +DROP TABLE IF EXISTS flocks; diff --git a/internal/database/migrations/20251015065815_add_flocs_table.up.sql b/internal/database/migrations/20251015065815_add_flocs_table.up.sql new file mode 100644 index 00000000..4752a3bd --- /dev/null +++ b/internal/database/migrations/20251015065815_add_flocs_table.up.sql @@ -0,0 +1,29 @@ +CREATE TABLE flocks ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX flocks_name_unique ON flocks (name) +WHERE + deleted_at IS NULL; + +CREATE TABLE project_flocks ( + id BIGSERIAL PRIMARY KEY, + flock_id BIGINT NOT NULL REFERENCES flocks (id) ON DELETE RESTRICT ON UPDATE CASCADE, + area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE, + product_category_id BIGINT NOT NULL REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE, + fcr_id BIGINT NOT NULL REFERENCES fcrs (id) ON DELETE RESTRICT ON UPDATE CASCADE, + location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE, + period INT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +ALTER TABLE kandangs + ADD COLUMN project_flock_id BIGINT REFERENCES project_flocks (id) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql b/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql new file mode 100644 index 00000000..f3cb3ddf --- /dev/null +++ b/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS project_flocks_flock_period_unique; diff --git a/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql b/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql new file mode 100644 index 00000000..40cebe2d --- /dev/null +++ b/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX project_flocks_flock_period_unique +ON project_flocks (flock_id, period) +WHERE deleted_at IS NULL; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index b321a784..839854cc 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -35,7 +35,27 @@ func Run(db *gorm.DB) error { return err } - kandangs, err := seedKandangs(tx, adminID, locations, users) + productCategories, err := seedProductCategories(tx, adminID) + if err != nil { + return err + } + + flocks, err := seedFlocks(tx, adminID) + if err != nil { + return err + } + + fcrs, err := seedFcr(tx, adminID) + if err != nil { + return err + } + + projectFlocks, err := seedProjectFlocks(tx, adminID, flocks, areas, productCategories, fcrs, locations) + if err != nil { + return err + } + + kandangs, err := seedKandangs(tx, adminID, locations, users, projectFlocks) if err != nil { return err } @@ -44,11 +64,6 @@ func Run(db *gorm.DB) error { return err } - productCategories, err := seedProductCategories(tx, adminID) - if err != nil { - return err - } - suppliers, err := seedSuppliers(tx, adminID) if err != nil { return err @@ -58,10 +73,6 @@ func Run(db *gorm.DB) error { return err } - if err := seedFcr(tx, adminID); err != nil { - return err - } - if err := seedProducts(tx, adminID, uoms, productCategories, suppliers); err != nil { return err } @@ -194,16 +205,138 @@ func seedLocations(tx *gorm.DB, createdBy uint, areas map[string]uint) (map[stri return result, nil } -func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { +func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { + names := []string{"Flock Priangan", "Flock Banten"} + result := make(map[string]uint, len(names)) + + for _, name := range names { + var flock entity.Flock + err := tx.Where("name = ?", name).First(&flock).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + flock = entity.Flock{ + Name: name, + CreatedBy: createdBy, + } + if err := tx.Create(&flock).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + if err := tx.Model(&entity.Flock{}).Where("id = ?", flock.Id).Updates(map[string]any{ + "created_by": createdBy, + }).Error; err != nil { + return nil, err + } + } + result[name] = flock.Id + } + + return result, nil +} + +func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, productCategories, fcrs, locations map[string]uint) (map[string]uint, error) { seeds := []struct { - Name string - Location string - PicKey string + Key string + Flock string + Area string + ProductCategory string + Fcr string + Location string + Period int }{ - {"Singaparna 1", "Singaparna", "admin"}, - {"Singaparna 2", "Singaparna", "admin"}, - {"Cikaum 1", "Cikaum", "admin"}, - {"Cikaum 2", "Cikaum", "admin"}, + { + Key: "Singaparna Period 1", + Flock: "Flock Priangan", + Area: "Priangan", + ProductCategory: "Day Old Chick", + Fcr: "FCR Layer", + Location: "Singaparna", + Period: 1, + }, + { + Key: "Cikaum Period 1", + Flock: "Flock Banten", + Area: "Banten", + ProductCategory: "Day Old Chick", + Fcr: "FCR Layer", + Location: "Cikaum", + Period: 1, + }, + } + + result := make(map[string]uint, len(seeds)) + + for _, seed := range seeds { + flockID, ok := flocks[seed.Flock] + if !ok { + return nil, fmt.Errorf("floc %s not seeded", seed.Flock) + } + areaID, ok := areas[seed.Area] + if !ok { + return nil, fmt.Errorf("area %s not seeded", seed.Area) + } + categoryID, ok := productCategories[seed.ProductCategory] + if !ok { + return nil, fmt.Errorf("product category %s not seeded", seed.ProductCategory) + } + fcrID, ok := fcrs[seed.Fcr] + if !ok { + return nil, fmt.Errorf("fcr %s not seeded", seed.Fcr) + } + locationID, ok := locations[seed.Location] + if !ok { + return nil, fmt.Errorf("location %s not seeded", seed.Location) + } + + var projectFlock entity.ProjectFlock + err := tx.Where("flock_id = ? AND area_id = ? AND product_category_id = ? AND fcr_id = ? AND location_id = ? AND period = ?", + flockID, areaID, categoryID, fcrID, locationID, seed.Period).First(&projectFlock).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + projectFlock = entity.ProjectFlock{ + FlockId: flockID, + AreaId: areaID, + ProductCategoryId: categoryID, + FcrId: fcrID, + LocationId: locationID, + Period: seed.Period, + CreatedBy: createdBy, + } + if err := tx.Create(&projectFlock).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + if err := tx.Model(&entity.ProjectFlock{}).Where("id = ?", projectFlock.Id).Updates(map[string]any{ + "flock_id": flockID, + "area_id": areaID, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationID, + "period": seed.Period, + }).Error; err != nil { + return nil, err + } + } + result[seed.Key] = projectFlock.Id + } + + return result, nil +} + +func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint, projectFlocks map[string]uint) (map[string]uint, error) { + seeds := []struct { + Name string + Status utils.KandangStatus + Location string + PicKey string + ProjectFlockKey *string + }{ + {Name: "Singaparna 1", Status: utils.KandangStatusActive, Location: "Singaparna", PicKey: "admin", ProjectFlockKey: strPtr("Singaparna Period 1")}, + {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin", ProjectFlockKey: strPtr("Singaparna Period 1")}, + {Name: "Cikaum 1", Status: utils.KandangStatusActive, Location: "Cikaum", PicKey: "admin", ProjectFlockKey: strPtr("Cikaum Period 1")}, + {Name: "Cikaum 2", Status: utils.KandangStatusPengajuan, Location: "Cikaum", PicKey: "admin"}, } result := make(map[string]uint, len(seeds)) @@ -218,20 +351,45 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return nil, fmt.Errorf("user %s not seeded", seed.PicKey) } + var projectFlockID *uint + if seed.ProjectFlockKey != nil { + pfID, ok := projectFlocks[*seed.ProjectFlockKey] + if !ok { + return nil, fmt.Errorf("project flock %s not seeded", *seed.ProjectFlockKey) + } + projectFlockID = uintPtr(pfID) + } + var kandang entity.Kandang err := tx.Where("name = ?", seed.Name).First(&kandang).Error if errors.Is(err, gorm.ErrRecordNotFound) { kandang = entity.Kandang{ - Name: seed.Name, - LocationId: locID, - PicId: picID, - CreatedBy: createdBy, + Name: seed.Name, + Status: string(seed.Status), + LocationId: locID, + PicId: picID, + ProjectFlockId: projectFlockID, + CreatedBy: createdBy, } if err := tx.Create(&kandang).Error; err != nil { return nil, err } } else if err != nil { return nil, err + } else { + updates := map[string]any{ + "location_id": locID, + "pic_id": picID, + "status": string(seed.Status), + } + if projectFlockID != nil { + updates["project_flock_id"] = *projectFlockID + } else { + updates["project_flock_id"] = nil + } + if err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { + return nil, err + } } result[seed.Name] = kandang.Id } @@ -430,7 +588,7 @@ func seedCustomers(tx *gorm.DB, createdBy uint, users map[string]uint) error { return nil } -func seedFcr(tx *gorm.DB, createdBy uint) error { +func seedFcr(tx *gorm.DB, createdBy uint) (map[string]uint, error) { seeds := []struct { Name string Standards []struct { @@ -452,17 +610,20 @@ func seedFcr(tx *gorm.DB, createdBy uint) error { }, } + result := make(map[string]uint, len(seeds)) + for _, seed := range seeds { var fcr entity.Fcr err := tx.Where("name = ?", seed.Name).First(&fcr).Error if errors.Is(err, gorm.ErrRecordNotFound) { fcr = entity.Fcr{Name: seed.Name, CreatedBy: createdBy} if err := tx.Create(&fcr).Error; err != nil { - return err + return nil, err } } else if err != nil { - return err + return nil, err } + result[seed.Name] = fcr.Id for _, std := range seed.Standards { var standard entity.FcrStandard @@ -475,22 +636,22 @@ func seedFcr(tx *gorm.DB, createdBy uint) error { Mortality: std.Mortality, } if err := tx.Create(&standard).Error; err != nil { - return err + return nil, err } } else if err != nil { - return err + return nil, err } else { if err := tx.Model(&entity.FcrStandard{}).Where("id = ?", standard.Id).Updates(map[string]any{ "fcr_number": std.FcrNumber, "mortality": std.Mortality, }).Error; err != nil { - return err + return nil, err } } } } - return nil + return result, nil } func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories map[string]uint, suppliers map[string]uint) error { diff --git a/internal/entities/flock.go b/internal/entities/flock.go new file mode 100644 index 00000000..dad9ba81 --- /dev/null +++ b/internal/entities/flock.go @@ -0,0 +1,17 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Flock struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:flocks_name_unique,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 862f40fc..c71382da 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -7,16 +7,18 @@ import ( ) type Kandang struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` - LocationId uint `gorm:"not null"` - PicId uint `gorm:"not null"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Location Location `gorm:"foreignKey:LocationId;references:Id"` - Pic User `gorm:"foreignKey:PicId;references:Id"` + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` + Status string `gorm:"type:varchar(50);not null"` + LocationId uint `gorm:"not null"` + PicId uint `gorm:"not null"` + ProjectFlockId *uint `gorm:"column:project_flock_id"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + Pic User `gorm:"foreignKey:PicId;references:Id"` + ProjectFlock *ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` } diff --git a/internal/entities/projectfloc.go b/internal/entities/projectfloc.go new file mode 100644 index 00000000..eee7392a --- /dev/null +++ b/internal/entities/projectfloc.go @@ -0,0 +1,28 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ProjectFlock struct { + Id uint `gorm:"primaryKey"` + FlockId uint `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:1"` + AreaId uint `gorm:"not null"` + ProductCategoryId uint `gorm:"not null"` + FcrId uint `gorm:"not null"` + LocationId uint `gorm:"not null"` + Period int `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:2"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Flock Flock `gorm:"foreignKey:FlockId;references:Id"` + Area Area `gorm:"foreignKey:AreaId;references:Id"` + ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` + Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` +} diff --git a/internal/entities/recording.go b/internal/entities/recording.go new file mode 100644 index 00000000..a6cf61b0 --- /dev/null +++ b/internal/entities/recording.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Recording struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 14a64337..d89dcb31 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,55 +1,99 @@ package middleware -import ( - "strings" +// import ( +// "strings" - "gitlab.com/mbugroup/lti-api.git/internal/config" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/utils" +// "gitlab.com/mbugroup/lti-api.git/internal/config" +// service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +// "gitlab.com/mbugroup/lti-api.git/internal/utils" - "github.com/gofiber/fiber/v2" -) +// "github.com/gofiber/fiber/v2" +// ) -func Auth(userService service.UserService, requiredRights ...string) fiber.Handler { - return func(c *fiber.Ctx) error { - authHeader := c.Get("Authorization") - token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) +// func Auth(userService service.UserService, requiredRights ...string) fiber.Handler { +// return func(c *fiber.Ctx) error { +// authHeader := c.Get("Authorization") +// token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) - if token == "" { - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } +// if token == "" { +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } - userID, err := utils.VerifyToken(token, config.JWTSecret, config.TokenTypeAccess) - if err != nil { - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } +// userID, err := utils.VerifyToken(token, config.JWTSecret, config.TokenTypeAccess) +// if err != nil { +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } - user, err := userService.GetOne(c, userID) - if err != nil || user == nil { - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } +// // Only end-user subjects are allowed by this middleware. Service tokens +// if verification.UserID == 0 { +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } - c.Locals("user", user) +// // Fail-closed on revocation check errors for stricter security posture. +// if revoker := session.GetRevocationStore(); revoker != nil { +// if fingerprint := session.TokenFingerprint(token); fingerprint != "" { +// revoked, err := revoker.IsRevoked(c.Context(), fingerprint) +// if err != nil { +// utils.Log.WithError(err).Warn("failed to check token revocation") +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } +// if revoked { +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } +// } +// } - // if len(requiredRights) > 0 { - // userRights, hasRights := config.RoleRights[user.Role] - // if (!hasRights || !hasAllRights(userRights, requiredRights)) && c.Params("userId") != userID { - // return fiber.NewError(fiber.StatusForbidden, "You don't have permission to access this resource") - // } - // } +// user, err := userService.GetBySSOUserID(c, verification.UserID) +// if err != nil || user == nil { +// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") +// } - return c.Next() - } -} +// if len(requiredRights) > 0 && verification.Claims != nil { +// if !hasAllScopes(verification.Claims.Scopes(), requiredRights) { +// return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") +// } +// } -// func hasAllRights(userRights, requiredRights []string) bool { -// rightSet := make(map[string]struct{}, len(userRights)) -// for _, right := range userRights { -// rightSet[right] = struct{}{} +// c.Locals("user", user) + +// // if len(requiredRights) > 0 { +// // userRights, hasRights := config.RoleRights[user.Role] +// // if (!hasRights || !hasAllRights(userRights, requiredRights)) && c.Params("userId") != userID { +// // return fiber.NewError(fiber.StatusForbidden, "You don't have permission to access this resource") +// // } +// // } + +// return c.Next() // } +// } -// for _, right := range requiredRights { -// if _, exists := rightSet[right]; !exists { +// // bearerToken extracts a Bearer token from the Authorization header using +// // case-insensitive scheme matching and tolerant whitespace handling. +// func bearerToken(c *fiber.Ctx) string { +// parts := strings.Fields(c.Get("Authorization")) +// if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { +// return strings.TrimSpace(parts[1]) +// } +// return "" +// } + +// func hasAllScopes(have, required []string) bool { +// if len(required) == 0 { +// return true +// } +// set := make(map[string]struct{}, len(have)) +// for _, s := range have { +// s = strings.ToLower(strings.TrimSpace(s)) +// if s != "" { +// set[s] = struct{}{} +// } +// } +// for _, r := range required { +// r = strings.ToLower(strings.TrimSpace(r)) +// if r == "" { +// continue +// } +// if _, ok := set[r]; !ok { // return false // } // } diff --git a/internal/modules/master/flocks/controllers/flock.controller.go b/internal/modules/master/flocks/controllers/flock.controller.go new file mode 100644 index 00000000..8265f3e4 --- /dev/null +++ b/internal/modules/master/flocks/controllers/flock.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type FlockController struct { + FlockService service.FlockService +} + +func NewFlockController(flockService service.FlockService) *FlockController { + return &FlockController{ + FlockService: flockService, + } +} + +func (u *FlockController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.FlockService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.FlockListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all flocks successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToFlockListDTOs(result), + }) +} + +func (u *FlockController) 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.FlockService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get flock successfully", + Data: dto.ToFlockListDTO(*result), + }) +} + +func (u *FlockController) 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.FlockService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create flock successfully", + Data: dto.ToFlockListDTO(*result), + }) +} + +func (u *FlockController) 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.FlockService.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 flock successfully", + Data: dto.ToFlockListDTO(*result), + }) +} + +func (u *FlockController) 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.FlockService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete flock successfully", + }) +} diff --git a/internal/modules/master/flocks/dto/flock.dto.go b/internal/modules/master/flocks/dto/flock.dto.go new file mode 100644 index 00000000..10e6f555 --- /dev/null +++ b/internal/modules/master/flocks/dto/flock.dto.go @@ -0,0 +1,64 @@ +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 FlockBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type FlockListDTO struct { + FlockBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type FlockDetailDTO struct { + FlockListDTO +} + +// === Mapper Functions === + +func ToFlockBaseDTO(e entity.Flock) FlockBaseDTO { + return FlockBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToFlockListDTO(e entity.Flock) FlockListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return FlockListDTO{ + FlockBaseDTO: ToFlockBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToFlockListDTOs(e []entity.Flock) []FlockListDTO { + result := make([]FlockListDTO, len(e)) + for i, r := range e { + result[i] = ToFlockListDTO(r) + } + return result +} + +func ToFlockDetailDTO(e entity.Flock) FlockDetailDTO { + return FlockDetailDTO{ + FlockListDTO: ToFlockListDTO(e), + } +} diff --git a/internal/modules/master/flocks/module.go b/internal/modules/master/flocks/module.go new file mode 100644 index 00000000..545e2583 --- /dev/null +++ b/internal/modules/master/flocks/module.go @@ -0,0 +1,25 @@ +package flocks + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + sFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type FlockModule struct{} + +func (FlockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + flockRepo := rFlock.NewFlockRepository(db) + userRepo := rUser.NewUserRepository(db) + + flockService := sFlock.NewFlockService(flockRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + FlockRoutes(router, userService, flockService) +} diff --git a/internal/modules/master/flocks/repositories/flock.repository.go b/internal/modules/master/flocks/repositories/flock.repository.go new file mode 100644 index 00000000..12f269fc --- /dev/null +++ b/internal/modules/master/flocks/repositories/flock.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 FlockRepository interface { + repository.BaseRepository[entity.Flock] +} + +type FlockRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Flock] +} + +func NewFlockRepository(db *gorm.DB) FlockRepository { + return &FlockRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Flock](db), + } +} diff --git a/internal/modules/master/flocks/route.go b/internal/modules/master/flocks/route.go new file mode 100644 index 00000000..6d93827d --- /dev/null +++ b/internal/modules/master/flocks/route.go @@ -0,0 +1,28 @@ +package flocks + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/controllers" + flock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func FlockRoutes(v1 fiber.Router, u user.UserService, s flock.FlockService) { + ctrl := controller.NewFlockController(s) + + route := v1.Group("/flocks") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + 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/flocks/services/flock.service.go b/internal/modules/master/flocks/services/flock.service.go new file mode 100644 index 00000000..4c3c9b26 --- /dev/null +++ b/internal/modules/master/flocks/services/flock.service.go @@ -0,0 +1,130 @@ +package service + +import ( + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/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 FlockService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Flock, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Flock, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Flock, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Flock, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type flockService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.FlockRepository +} + +func NewFlockService(repo repository.FlockRepository, validate *validator.Validate) FlockService { + return &flockService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s flockService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s flockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Flock, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + flocks, 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 flocks: %+v", err) + return nil, 0, err + } + return flocks, total, nil +} + +func (s flockService) GetOne(c *fiber.Ctx, id uint) (*entity.Flock, error) { + flock, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Flock not found") + } + if err != nil { + s.Log.Errorf("Failed get flock by id: %+v", err) + return nil, err + } + return flock, nil +} + +func (s *flockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Flock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + createBody := &entity.Flock{ + Name: req.Name, + CreatedBy: 1, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create flock: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s flockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Flock, 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, "Flock not found") + } + s.Log.Errorf("Failed to update flock: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s flockService) 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, "Flock not found") + } + s.Log.Errorf("Failed to delete flock: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/flocks/validations/floc.validation.go b/internal/modules/master/flocks/validations/floc.validation.go new file mode 100644 index 00000000..95505746 --- /dev/null +++ b/internal/modules/master/flocks/validations/floc.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +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"` +} diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index d40498af..deed483c 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -13,6 +13,7 @@ import ( type KandangBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` + Status string `json:"status"` Location *locationDTO.LocationBaseDTO `json:"location"` Pic *userDTO.UserBaseDTO `json:"pic"` } @@ -46,6 +47,7 @@ func ToKandangBaseDTO(e entity.Kandang) KandangBaseDTO { return KandangBaseDTO{ Id: e.Id, Name: e.Name, + Status: e.Status, Location: location, Pic: pic, } diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go index c72eb87f..b253fade 100644 --- a/internal/modules/master/kandangs/repositories/kandang.repository.go +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -5,6 +5,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -13,6 +14,8 @@ type KandangRepository interface { LocationExists(ctx context.Context, areaId uint) (bool, error) PicExists(ctx context.Context, areaId uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + ProjectFlockExists(ctx context.Context, projectFlockID uint) (bool, error) + HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) } type KandangRepositoryImpl struct { @@ -38,3 +41,31 @@ func (r *KandangRepositoryImpl) PicExists(ctx context.Context, picId uint) (bool func (r *KandangRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Kandang](ctx, r.db, name, excludeID) } + +func (r *KandangRepositoryImpl) ProjectFlockExists(ctx context.Context, projectFlockID uint) (bool, error) { + var count int64 + if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlock{}). + Where("id = ?", projectFlockID). + Where("deleted_at IS NULL"). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *KandangRepositoryImpl) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) { + var count int64 + q := r.db.WithContext(ctx). + Model(&entity.Kandang{}). + Where("project_flock_id = ?", projectFlockID). + Where("status = ?", utils.KandangStatusActive). + Where("deleted_at IS NULL") + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index d856f736..6e836170 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "strings" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -101,12 +102,44 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } + status := strings.ToUpper(strings.TrimSpace(req.Status)) + if status == "" { + status = string(utils.KandangStatusNonActive) + } + if !utils.IsValidKandangStatus(status) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang status") + } + + var projectFlockID *uint + if req.ProjectFlockId != nil { + if exists, err := s.Repository.ProjectFlockExists(c.Context(), *req.ProjectFlockId); err != nil { + s.Log.Errorf("Failed to check project flock existence: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check project flock") + } else if !exists { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Project flock with id %d not found", *req.ProjectFlockId)) + } + + if status == string(utils.KandangStatusActive) { + if active, err := s.Repository.HasActiveKandangForProjectFlock(c.Context(), *req.ProjectFlockId, nil); err != nil { + s.Log.Errorf("Failed to check kandang activity for project flock %d: %+v", *req.ProjectFlockId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check active kandang for project flock") + } else if active { + return nil, fiber.NewError(fiber.StatusConflict, "Project flock already has an active kandang") + } + } + + idCopy := *req.ProjectFlockId + projectFlockID = &idCopy + } + //TODO: created by dummy createBody := &entity.Kandang{ - Name: req.Name, - LocationId: req.LocationId, - PicId: req.PicId, - CreatedBy: 1, + Name: req.Name, + LocationId: req.LocationId, + Status: status, + PicId: req.PicId, + ProjectFlockId: projectFlockID, + CreatedBy: 1, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { @@ -122,6 +155,15 @@ func (s kandangService) 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, "Kandang not found") + } + if err != nil { + s.Log.Errorf("Failed to fetch kandang %d before update: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandang") + } + updateBody := make(map[string]any) if req.Name != nil { @@ -149,6 +191,38 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["pic_id"] = *req.PicId } + finalStatus := strings.ToUpper(existing.Status) + if req.Status != nil { + status := strings.ToUpper(*req.Status) + if !utils.IsValidKandangStatus(status) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang status") + } + updateBody["status"] = status + finalStatus = status + } + + projectFlockIDToUse := existing.ProjectFlockId + if req.ProjectFlockId != nil { + if exists, err := s.Repository.ProjectFlockExists(c.Context(), *req.ProjectFlockId); err != nil { + s.Log.Errorf("Failed to check project flock existence: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check project flock") + } else if !exists { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Project flock with id %d not found", *req.ProjectFlockId)) + } + idCopy := *req.ProjectFlockId + projectFlockIDToUse = &idCopy + updateBody["project_flock_id"] = idCopy + } + + if projectFlockIDToUse != nil && finalStatus == string(utils.KandangStatusActive) { + if active, err := s.Repository.HasActiveKandangForProjectFlock(c.Context(), *projectFlockIDToUse, &id); err != nil { + s.Log.Errorf("Failed to check kandang activity for project flock %d: %+v", *projectFlockIDToUse, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check active kandang for project flock") + } else if active { + return nil, fiber.NewError(fiber.StatusConflict, "Project flock already has an active kandang") + } + } + if len(updateBody) == 0 { return s.GetOne(c, id) } diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index 8b986ca6..f6886991 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -1,15 +1,19 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` + Name string `json:"name" validate:"required_strict,min=3"` + Status string `json:"status,omitempty" validate:"omitempty,min=3"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` + ProjectFlockId *uint `json:"project_flock_id" validate:"omitempty,number,gt=0"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` - LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` - PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` + Name *string `json:"name,omitempty" validate:"omitempty"` + Status *string `json:"status,omitempty" validate:"omitempty,min=3"` + LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` + PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` + ProjectFlockId *uint `json:"project_flock_id,omitempty" validate:"omitempty,number,gt=0"` } type Query struct { diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 88584c13..88e17a98 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -19,6 +19,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" + flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks" // MODULE IMPORTS ) @@ -38,6 +39,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida productcategories.ProductCategoryModule{}, products.ProductModule{}, banks.BankModule{}, + flocks.FlockModule{}, // MODULE REGISTRY } diff --git a/internal/modules/production/module.go b/internal/modules/production/module.go new file mode 100644 index 00000000..d10cf983 --- /dev/null +++ b/internal/modules/production/module.go @@ -0,0 +1,13 @@ +package production + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type ProductionModule struct{} + +func (ProductionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + RegisterRoutes(router, db, validate) +} diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go new file mode 100644 index 00000000..48134164 --- /dev/null +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -0,0 +1,164 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProjectflockController struct { + ProjectflockService service.ProjectflockService +} + +func NewProjectflockController(projectflockService service.ProjectflockService) *ProjectflockController { + return &ProjectflockController{ + ProjectflockService: projectflockService, + } +} + +func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.ProjectflockService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProjectFlockListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all projectflocks successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProjectFlockListDTOs(result), + }) +} + +func (u *ProjectflockController) 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.ProjectflockService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get projectflock successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} + +func (u *ProjectflockController) 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.ProjectflockService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create projectflock successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} + +func (u *ProjectflockController) 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.ProjectflockService.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 projectflock successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} + +func (u *ProjectflockController) 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.ProjectflockService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete projectflock successfully", + }) +} + +func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { + param := c.Params("flock_id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Flock Id") + } + + summary, err := u.ProjectflockService.GetFlockPeriodSummary(c, uint(id)) + if err != nil { + return err + } + + responseBody := dto.ToFlockPeriodSummaryDTO(summary.Flock, summary.NextPeriod) + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get flock period summary successfully", + Data: responseBody, + }) +} diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go new file mode 100644 index 00000000..227d0fe9 --- /dev/null +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -0,0 +1,183 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +type ProjectFlockBaseDTO struct { + Id uint `json:"id"` + // FlockId uint `json:"flock_id"` + // AreaId uint `json:"area_id"` + // ProductCategoryId uint `json:"product_category_id"` + // FcrId uint `json:"fcr_id"` + // LocationId uint `json:"location_id"` + Period int `json:"period"` +} + +func ToProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { + return ProjectFlockBaseDTO{ + Id: e.Id, + // FlockId: e.FlockId, + // AreaId: e.AreaId, + // ProductCategoryId: e.ProductCategoryId, + // FcrId: e.FcrId, + // LocationId: e.LocationId, + Period: e.Period, + } +} + +type FlockSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type AreaSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ProductCategorySummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` +} + +type FcrSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Address string `json:"address"` +} + +type KandangSummaryDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + +type ProjectFlockListDTO struct { + ProjectFlockBaseDTO + Flock *FlockSummaryDTO `json:"flock,omitempty"` + Area *AreaSummaryDTO `json:"area,omitempty"` + ProductCategory *ProductCategorySummaryDTO `json:"product_category,omitempty"` + Fcr *FcrSummaryDTO `json:"fcr,omitempty"` + Location *LocationSummaryDTO `json:"location,omitempty"` + Kandangs []KandangSummaryDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProjectFlockDetailDTO struct { + ProjectFlockListDTO +} + +type FlockPeriodSummaryDTO struct { + Flock FlockSummaryDTO `json:"flock"` + NextPeriod int `json:"next_period"` +} + +func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + var flockSummary *FlockSummaryDTO + if e.Flock.Id != 0 { + summary := ToFlockSummaryDTO(e.Flock) + flockSummary = &summary + } + + var areaSummary *AreaSummaryDTO + if e.Area.Id != 0 { + areaSummary = &AreaSummaryDTO{ + Id: e.Area.Id, + Name: e.Area.Name, + } + } + + var categorySummary *ProductCategorySummaryDTO + if e.ProductCategory.Id != 0 { + categorySummary = &ProductCategorySummaryDTO{ + Id: e.ProductCategory.Id, + Name: e.ProductCategory.Name, + Code: e.ProductCategory.Code, + } + } + + var fcrSummary *FcrSummaryDTO + if e.Fcr.Id != 0 { + fcrSummary = &FcrSummaryDTO{ + Id: e.Fcr.Id, + Name: e.Fcr.Name, + } + } + + var locationSummary *LocationSummaryDTO + if e.Location.Id != 0 { + locationSummary = &LocationSummaryDTO{ + Id: e.Location.Id, + Name: e.Location.Name, + Address: e.Location.Address, + } + } + + kandangSummaries := make([]KandangSummaryDTO, len(e.Kandangs)) + for i, kandang := range e.Kandangs { + kandangSummaries[i] = KandangSummaryDTO{ + Id: kandang.Id, + Name: kandang.Name, + Status: kandang.Status, + } + } + + return ProjectFlockListDTO{ + ProjectFlockBaseDTO: ToProjectFlockBaseDTO(e), + Flock: flockSummary, + Area: areaSummary, + ProductCategory: categorySummary, + Fcr: fcrSummary, + Location: locationSummary, + Kandangs: kandangSummaries, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToProjectFlockListDTOs(items []entity.ProjectFlock) []ProjectFlockListDTO { + result := make([]ProjectFlockListDTO, len(items)) + for i, item := range items { + result[i] = ToProjectFlockListDTO(item) + } + return result +} + +func ToProjectFlockDetailDTO(e entity.ProjectFlock) ProjectFlockDetailDTO { + return ProjectFlockDetailDTO{ + ProjectFlockListDTO: ToProjectFlockListDTO(e), + } +} + +func ToFlockSummaryDTO(e entity.Flock) FlockSummaryDTO { + return FlockSummaryDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToFlockPeriodSummaryDTO(flock entity.Flock, next int) FlockPeriodSummaryDTO { + return FlockPeriodSummaryDTO{ + Flock: ToFlockSummaryDTO(flock), + NextPeriod: next, + } +} diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go new file mode 100644 index 00000000..4f3167bc --- /dev/null +++ b/internal/modules/production/project_flocks/module.go @@ -0,0 +1,29 @@ +package project_flocks + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProjectflockModule struct{} + +func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + flockRepo := rFlock.NewFlockRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) + projectflockRepo := rProjectflock.NewProjectflockRepository(db) + userRepo := rUser.NewUserRepository(db) + + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ProjectflockRoutes(router, userService, projectflockService) +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go new file mode 100644 index 00000000..476b061b --- /dev/null +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -0,0 +1,88 @@ +package repository + +import ( + "context" + "errors" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type ProjectflockRepository interface { + repository.BaseRepository[entity.ProjectFlock] + GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) + GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) + GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) + GetNextPeriodForFlock(ctx context.Context, flockID uint) (int, error) +} + +type ProjectflockRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectFlock] +} + +func NewProjectflockRepository(db *gorm.DB) ProjectflockRepository { + return &ProjectflockRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlock](db), + } +} + +func (r *ProjectflockRepositoryImpl) GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) { + var records []entity.ProjectFlock + if err := r.DB().WithContext(ctx). + Unscoped(). + Where("flock_id = ?", flockID). + Order("period ASC"). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +func (r *ProjectflockRepositoryImpl) GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) { + var record entity.ProjectFlock + err := r.DB().WithContext(ctx). + Where("flock_id = ?", flockID). + Order("period DESC"). + First(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) { + var max int + if err := r.DB().WithContext(ctx). + Model(&entity.ProjectFlock{}). + Where("flock_id = ?", flockID). + Select("COALESCE(MAX(period), 0)"). + Scan(&max).Error; err != nil { + return 0, err + } + return max, nil +} + +func (r *ProjectflockRepositoryImpl) GetNextPeriodForFlock(ctx context.Context, flockID uint) (int, error) { + var payload struct { + Period int + } + if err := r.DB().WithContext(ctx). + Model(&entity.ProjectFlock{}). + Where("flock_id = ?", flockID). + Clauses(clause.Locking{Strength: "UPDATE"}). + Order("period DESC"). + Limit(1). + Select("period"). + Scan(&payload).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 1, nil + } + return 0, err + } + return payload.Period + 1, nil +} diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go new file mode 100644 index 00000000..e5dbb48a --- /dev/null +++ b/internal/modules/production/project_flocks/route.go @@ -0,0 +1,29 @@ +package project_flocks + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/controllers" + projectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.ProjectflockService) { + ctrl := controller.NewProjectflockController(s) + + route := v1.Group("/project_flocks") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) + route.Get("/flocks/:flock_id/periods", ctrl.GetFlockPeriodSummary) +} diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go new file mode 100644 index 00000000..e9ad3ddb --- /dev/null +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -0,0 +1,433 @@ +package service + +import ( + "context" + "errors" + "fmt" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" + kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" + "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 ProjectflockService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) +} + +type projectflockService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectflockRepository + FlockRepo flockRepository.FlockRepository + KandangRepo kandangRepository.KandangRepository +} + +type FlockPeriodSummary struct { + Flock entity.Flock + NextPeriod int +} + +func NewProjectflockService( + repo repository.ProjectflockRepository, + flockRepo flockRepository.FlockRepository, + kandangRepo kandangRepository.KandangRepository, + validate *validator.Validate, +) ProjectflockService { + return &projectflockService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + FlockRepo: flockRepo, + KandangRepo: kandangRepo, + } +} + +func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Flock"). + Preload("Area"). + Preload("ProductCategory"). + Preload("Fcr"). + Preload("Location"). + Preload("Kandangs") +} + +func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + projectflocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get projectflocks: %+v", err) + return nil, 0, err + } + return projectflocks, total, nil +} + +func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { + projectflock, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + if err != nil { + s.Log.Errorf("Failed get projectflock by id: %+v", err) + return nil, err + } + return projectflock, nil +} + +func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if len(req.KandangIds) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required") + } + + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, + common.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, + common.RelationCheck{Name: "Product category", ID: &req.ProductCategoryId, Exists: relationExistsChecker[entity.ProductCategory](s.Repository.DB())}, + common.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, + common.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB())}, + ); err != nil { + return nil, err + } + + kandangIDs := uniqueUintSlice(req.KandangIds) + kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") + } + if len(kandangs) != len(kandangIDs) { + return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") + } + for _, kandang := range kandangs { + if kandang.ProjectFlockId != nil { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang %s sudah memiliki project flock", kandang.Name)) + } + } + + tx := s.Repository.DB().Begin() + if tx.Error != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + + projectRepo := repository.NewProjectflockRepository(tx) + nextPeriod, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) + if err != nil { + tx.Rollback() + s.Log.Errorf("Failed to determine next period for flock %d: %+v", req.FlockId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine next period") + } + + createBody := &entity.ProjectFlock{ + FlockId: req.FlockId, + AreaId: req.AreaId, + ProductCategoryId: req.ProductCategoryId, + FcrId: req.FcrId, + LocationId: req.LocationId, + Period: nextPeriod, + CreatedBy: 1, + } + + if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { + tx.Rollback() + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") + } + s.Log.Errorf("Failed to create projectflock: %+v", err) + return nil, err + } + + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", kandangIDs). + Updates(map[string]any{"project_flock_id": createBody.Id}).Error; err != nil { + tx.Rollback() + s.Log.Errorf("Failed to assign kandangs to projectflock: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to assign kandangs") + } + + if err := tx.Commit().Error; err != nil { + tx.Rollback() + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } + + return s.GetOne(c, createBody.Id) +} + +func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + if err != nil { + s.Log.Errorf("Failed to fetch projectflock %d before update: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + updateBody := make(map[string]any) + var relationChecks []common.RelationCheck + + if req.FlockId != nil { + updateBody["flock_id"] = *req.FlockId + relationChecks = append(relationChecks, common.RelationCheck{ + Name: "Flock", + ID: req.FlockId, + Exists: relationExistsChecker[entity.Flock](s.Repository.DB()), + }) + } + if req.AreaId != nil { + updateBody["area_id"] = *req.AreaId + relationChecks = append(relationChecks, common.RelationCheck{ + Name: "Area", + ID: req.AreaId, + Exists: relationExistsChecker[entity.Area](s.Repository.DB()), + }) + } + if req.ProductCategoryId != nil { + updateBody["product_category_id"] = *req.ProductCategoryId + relationChecks = append(relationChecks, common.RelationCheck{ + Name: "Product category", + ID: req.ProductCategoryId, + Exists: relationExistsChecker[entity.ProductCategory](s.Repository.DB()), + }) + } + if req.FcrId != nil { + updateBody["fcr_id"] = *req.FcrId + relationChecks = append(relationChecks, common.RelationCheck{ + Name: "FCR", + ID: req.FcrId, + Exists: relationExistsChecker[entity.Fcr](s.Repository.DB()), + }) + } + if req.LocationId != nil { + updateBody["location_id"] = *req.LocationId + relationChecks = append(relationChecks, common.RelationCheck{ + Name: "Location", + ID: req.LocationId, + Exists: relationExistsChecker[entity.Location](s.Repository.DB()), + }) + } + if req.Period != nil { + updateBody["period"] = *req.Period + } + + if len(relationChecks) > 0 { + if err := common.EnsureRelations(c.Context(), relationChecks...); err != nil { + return nil, err + } + } + + var newKandangIDs []uint + if req.KandangIds != nil { + if len(req.KandangIds) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids cannot be empty") + } + newKandangIDs = uniqueUintSlice(req.KandangIds) + kandangs, err := s.KandangRepo.GetByIDs(c.Context(), newKandangIDs, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") + } + if len(kandangs) != len(newKandangIDs) { + return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") + } + for _, k := range kandangs { + if k.ProjectFlockId != nil && *k.ProjectFlockId != id { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang %s sudah terikat dengan project flock lain", k.Name)) + } + } + } + + tx := s.Repository.DB().Begin() + if tx.Error != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + + projectRepo := repository.NewProjectflockRepository(tx) + if len(updateBody) > 0 { + if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { + tx.Rollback() + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to update projectflock: %+v", err) + return nil, err + } + } + + if req.KandangIds != nil { + existingIDs := make(map[uint]struct{}, len(existing.Kandangs)) + for _, k := range existing.Kandangs { + existingIDs[k.Id] = struct{}{} + } + newSet := make(map[uint]struct{}, len(newKandangIDs)) + for _, id := range newKandangIDs { + newSet[id] = struct{}{} + } + + var toDetach []uint + for id := range existingIDs { + if _, ok := newSet[id]; !ok { + toDetach = append(toDetach, id) + } + } + + var toAttach []uint + for id := range newSet { + if _, ok := existingIDs[id]; !ok { + toAttach = append(toAttach, id) + } + } + + if len(toDetach) > 0 { + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", toDetach). + Updates(map[string]any{"project_flock_id": nil}).Error; err != nil { + tx.Rollback() + s.Log.Errorf("Failed to detach kandangs: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + } + } + + if len(toAttach) > 0 { + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", toAttach). + Updates(map[string]any{"project_flock_id": id}).Error; err != nil { + tx.Rollback() + s.Log.Errorf("Failed to attach kandangs: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + } + } + } + + if err := tx.Commit().Error; err != nil { + tx.Rollback() + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } + + return s.GetOne(c, id) +} + +func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { + existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + if err != nil { + s.Log.Errorf("Failed to fetch projectflock %d before delete: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + tx := s.Repository.DB().Begin() + if tx.Error != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + + if len(existing.Kandangs) > 0 { + ids := make([]uint, len(existing.Kandangs)) + for i, k := range existing.Kandangs { + ids[i] = k.Id + } + if err := tx.Model(&entity.Kandang{}). + Where("id IN ?", ids). + Updates(map[string]any{"project_flock_id": nil}).Error; err != nil { + tx.Rollback() + s.Log.Errorf("Failed to detach kandangs before delete: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + } + } + + if err := repository.NewProjectflockRepository(tx).DeleteOne(c.Context(), id); err != nil { + tx.Rollback() + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to delete projectflock: %+v", err) + return err + } + + if err := tx.Commit().Error; err != nil { + tx.Rollback() + return fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } + + return nil +} + +func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { + flock, err := s.FlockRepo.GetByID(c.Context(), flockID, func(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") + }) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Flock not found") + } + if err != nil { + s.Log.Errorf("Failed get flock %d for period summary: %+v", flockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock") + } + + maxPeriod, err := s.Repository.GetMaxPeriodByFlock(c.Context(), flockID) + if err != nil { + s.Log.Errorf("Failed to compute next period for flock %d: %+v", flockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to compute next period") + } + + return &FlockPeriodSummary{ + Flock: *flock, + NextPeriod: maxPeriod + 1, + }, nil +} + +func uniqueUintSlice(values []uint) []uint { + seen := make(map[uint]struct{}, len(values)) + result := make([]uint, 0, len(values)) + for _, v := range values { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + return result +} + +func relationExistsChecker[T any](db *gorm.DB) func(context.Context, uint) (bool, error) { + return func(ctx context.Context, id uint) (bool, error) { + return commonRepo.Exists[T](ctx, db, id) + } +} diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go new file mode 100644 index 00000000..8c1f7d06 --- /dev/null +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -0,0 +1,26 @@ +package validation + +type Create struct { + FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` + ProductCategoryId uint `json:"product_category_id" validate:"required_strict,number,gt=0"` + FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` +} + +type Update struct { + FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"` + AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` + ProductCategoryId *uint `json:"product_category_id,omitempty" validate:"omitempty,number,gt=0"` + FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` + LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` + Period *int `json:"period,omitempty" validate:"omitempty,number,gt=0"` + KandangIds []uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,gt=0"` +} + +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"` +} diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go new file mode 100644 index 00000000..1215e8fc --- /dev/null +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type RecordingController struct { + RecordingService service.RecordingService +} + +func NewRecordingController(recordingService service.RecordingService) *RecordingController { + return &RecordingController{ + RecordingService: recordingService, + } +} + +func (u *RecordingController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.RecordingService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.RecordingListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all recordings successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToRecordingListDTOs(result), + }) +} + +func (u *RecordingController) 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.RecordingService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get recording successfully", + Data: dto.ToRecordingListDTO(*result), + }) +} + +func (u *RecordingController) 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.RecordingService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create recording successfully", + Data: dto.ToRecordingListDTO(*result), + }) +} + +func (u *RecordingController) 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.RecordingService.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 recording successfully", + Data: dto.ToRecordingListDTO(*result), + }) +} + +func (u *RecordingController) 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.RecordingService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete recording successfully", + }) +} diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go new file mode 100644 index 00000000..7dbdec98 --- /dev/null +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -0,0 +1,64 @@ +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 RecordingBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type RecordingListDTO struct { + RecordingBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RecordingDetailDTO struct { + RecordingListDTO +} + +// === Mapper Functions === + +func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { + return RecordingBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToRecordingListDTO(e entity.Recording) RecordingListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return RecordingListDTO{ + RecordingBaseDTO: ToRecordingBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToRecordingListDTOs(e []entity.Recording) []RecordingListDTO { + result := make([]RecordingListDTO, len(e)) + for i, r := range e { + result[i] = ToRecordingListDTO(r) + } + return result +} + +func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { + return RecordingDetailDTO{ + RecordingListDTO: ToRecordingListDTO(e), + } +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go new file mode 100644 index 00000000..36ae8dd7 --- /dev/null +++ b/internal/modules/production/recordings/module.go @@ -0,0 +1,26 @@ +package recordings + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type RecordingModule struct{} + +func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + recordingRepo := rRecording.NewRecordingRepository(db) + userRepo := rUser.NewUserRepository(db) + + recordingService := sRecording.NewRecordingService(recordingRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + RecordingRoutes(router, userService, recordingService) +} + diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go new file mode 100644 index 00000000..8dd114d1 --- /dev/null +++ b/internal/modules/production/recordings/repositories/recording.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 RecordingRepository interface { + repository.BaseRepository[entity.Recording] +} + +type RecordingRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Recording] +} + +func NewRecordingRepository(db *gorm.DB) RecordingRepository { + return &RecordingRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Recording](db), + } +} diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go new file mode 100644 index 00000000..6852a1ba --- /dev/null +++ b/internal/modules/production/recordings/route.go @@ -0,0 +1,28 @@ +package recordings + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/controllers" + recording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingService) { + ctrl := controller.NewRecordingController(s) + + route := v1.Group("/recordings") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + 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/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go new file mode 100644 index 00000000..84220bd2 --- /dev/null +++ b/internal/modules/production/recordings/services/recording.service.go @@ -0,0 +1,129 @@ +package service + +import ( + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type RecordingService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type recordingService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.RecordingRepository +} + +func NewRecordingService(repo repository.RecordingRepository, validate *validator.Validate) RecordingService { + return &recordingService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s recordingService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + recordings, 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 recordings: %+v", err) + return nil, 0, err + } + return recordings, total, nil +} + +func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { + recording, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + if err != nil { + s.Log.Errorf("Failed get recording by id: %+v", err) + return nil, err + } + return recording, nil +} + +func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + createBody := &entity.Recording{ + Name: req.Name, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create recording: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, 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, "Recording not found") + } + s.Log.Errorf("Failed to update recording: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s recordingService) 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, "Recording not found") + } + s.Log.Errorf("Failed to delete recording: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go new file mode 100644 index 00000000..95505746 --- /dev/null +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +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"` +} diff --git a/internal/modules/production/route.go b/internal/modules/production/route.go new file mode 100644 index 00000000..73bbe8da --- /dev/null +++ b/internal/modules/production/route.go @@ -0,0 +1,27 @@ +package production + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/modules" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks" + recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" + // MODULE IMPORTS +) + +func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + group := router.Group("/production") + + allModules := []modules.Module{ + projectflocks.ProjectflockModule{}, + recordings.RecordingModule{}, + // MODULE REGISTRY + } + + for _, m := range allModules { + m.RegisterRoutes(group, db, validate) + } +} diff --git a/internal/route/route.go b/internal/route/route.go index 82b48166..b1cd62a4 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -12,6 +12,7 @@ import ( inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" + production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" // MODULE IMPORTS ) @@ -26,6 +27,7 @@ func Routes(app *fiber.App, db *gorm.DB) { master.MasterModule{}, constants.ConstantModule{}, inventory.InventoryModule{}, + production.ProductionModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 941c8a5e..dbc06660 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -59,6 +59,7 @@ var allFlagTypes = func() map[FlagType]struct{} { return m }() + func AllFlagTypes() map[FlagType]struct{} { return allFlagTypes } @@ -75,6 +76,8 @@ const ( WarehouseTypeKandang WarehouseType = "KANDANG" ) + + // ------------------------------------------------------------------- // WarehouseType // ------------------------------------------------------------------- @@ -97,6 +100,19 @@ const ( SupplierCategorySapronak SupplierCategory = "SAPRONAK" ) + + +// ------------------------------------------------------------------- +// Kandang Status +// ------------------------------------------------------------------- + +type KandangStatus string + +const ( + KandangStatusNonActive KandangStatus = "NON_ACTIVE" + KandangStatusPengajuan KandangStatus = "PENGAJUAN" + KandangStatusActive KandangStatus = "ACTIVE" +) // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -191,6 +207,14 @@ func IsValidWarehouseType(v string) bool { return false } +func IsValidKandangStatus(v string) bool { + switch KandangStatus(v) { + case KandangStatusNonActive, KandangStatusPengajuan, KandangStatusActive: + return true + } + return false +} + func IsValidCustomerSupplierType(v string) bool { switch CustomerSupplierType(v) { case CustomerSupplierTypeBisnis, CustomerSupplierTypeIndividual: diff --git a/test/integration/master_data/kandang_test.go b/test/integration/master_data/kandang_test.go index e17c8ad5..580196d4 100644 --- a/test/integration/master_data/kandang_test.go +++ b/test/integration/master_data/kandang_test.go @@ -1,14 +1,17 @@ package test import ( + "encoding/json" "net/http" "testing" "github.com/gofiber/fiber/v2" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" ) func TestKandangIntegration(t *testing.T) { - app, _ := setupIntegrationApp(t) + app, db := setupIntegrationApp(t) areaID := createArea(t, app, "Area Kandang") locationID := createLocation(t, app, "Location For Kandang", "Address", areaID) @@ -21,11 +24,24 @@ func TestKandangIntegration(t *testing.T) { if resp.StatusCode != fiber.StatusCreated { t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(body)) } + + var createResp struct { + Data struct { + Status string `json:"status"` + } `json:"data"` + } + if err := json.Unmarshal(body, &createResp); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + if createResp.Data.Status == "" { + t.Fatalf("expected default status to be returned, got empty") + } }) t.Run("create kandang with unknown location fails", func(t *testing.T) { resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ "name": "Kandang Fail", + "status": "ACTIVE", "location_id": 999, "pic_id": 1, }) @@ -33,4 +49,47 @@ func TestKandangIntegration(t *testing.T) { t.Fatalf("expected 404, got %d: %s", resp.StatusCode, string(body)) } }) + + t.Run("cannot assign project floc with existing active kandang", func(t *testing.T) { + categoryID := createProductCategory(t, app, "DOC Category", "DOC1") + fcrID := createFcr(t, app, "FCR For Floc", []map[string]any{ + {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, + }) + flocID := createFlock(t, app, "Floc Test") + + projectFloc := entities.ProjectFlock{ + FlockId: flocID, + AreaId: areaID, + ProductCategoryId: categoryID, + FcrId: fcrID, + LocationId: locationID, + Period: 1, + CreatedBy: 1, + } + if err := db.Create(&projectFloc).Error; err != nil { + t.Fatalf("failed to seed project floc: %v", err) + } + + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ + "name": "Kandang Active 1", + "status": "ACTIVE", + "location_id": locationID, + "pic_id": 1, + "project_flock_id": projectFloc.Id, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating first kandang, got %d: %s", resp.StatusCode, string(body)) + } + + resp, body = doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ + "name": "Kandang Active 2", + "status": "ACTIVE", + "location_id": locationID, + "pic_id": 1, + "project_flock_id": projectFloc.Id, + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when creating second active kandang, got %d: %s", resp.StatusCode, string(body)) + } + }) } diff --git a/test/integration/master_data/master_data.go b/test/integration/master_data/master_data.go index 1ccc4fea..f206808f 100644 --- a/test/integration/master_data/master_data.go +++ b/test/integration/master_data/master_data.go @@ -40,6 +40,8 @@ func setupIntegrationApp(t *testing.T) (*fiber.App, *gorm.DB) { &entities.User{}, &entities.Area{}, &entities.Location{}, + &entities.Flock{}, + &entities.ProjectFlock{}, &entities.Kandang{}, &entities.Warehouse{}, &entities.Uom{}, @@ -152,6 +154,7 @@ func createKandang(t *testing.T, app *fiber.App, name string, locationID, picID t.Helper() resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ "name": name, + "status": "ACTIVE", "location_id": locationID, "pic_id": picID, }) @@ -291,6 +294,17 @@ func createFcr(t *testing.T, app *fiber.App, name string, standards []map[string return parseID(t, body) } +func createFlock(t *testing.T, app *fiber.App, name string) uint { + t.Helper() + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/flocks", map[string]any{ + "name": name, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating flock, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + func fetchFcr(t *testing.T, db *gorm.DB, id uint) entities.Fcr { t.Helper() var fcr entities.Fcr diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go new file mode 100644 index 00000000..59698ae9 --- /dev/null +++ b/test/integration/master_data/project_flock_test.go @@ -0,0 +1,168 @@ +package test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestProjectFlockSummary(t *testing.T) { + app, _ := setupIntegrationApp(t) + + areaID := createArea(t, app, "Area Project") + locationID := createLocation(t, app, "Location Project", "Address", areaID) + flockID := createFlock(t, app, "Flock Summary") + categoryID := createProductCategory(t, app, "DOC Summary", "DOCS") + fcrID := createFcr(t, app, "FCR Summary", []map[string]any{ + {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, + }) + kandangID := createKandang(t, app, "Kandang Summary", locationID, 1) + + createPayload := map[string]any{ + "flock_id": flockID, + "area_id": areaID, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationID, + "kandang_ids": []uint{kandangID}, + } + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) + } + + var createResp struct { + Data struct { + Id uint `json:"id"` + Period int `json:"period"` + Flock struct { + Id uint `json:"id"` + Name string `json:"name"` + } `json:"flock"` + Area struct { + Id uint `json:"id"` + Name string `json:"name"` + } `json:"area"` + ProductCategory struct { + Id uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + } `json:"product_category"` + Fcr struct { + Id uint `json:"id"` + Name string `json:"name"` + } `json:"fcr"` + Location struct { + Id uint `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + } `json:"location"` + Kandangs []struct { + Id uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"kandangs"` + CreatedUser struct { + Id uint `json:"id"` + IdUser uint `json:"id_user"` + Email string `json:"email"` + Name string `json:"name"` + } `json:"created_user"` + } `json:"data"` + } + if err := json.Unmarshal(body, &createResp); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + if createResp.Data.Flock.Id != flockID || createResp.Data.Flock.Name == "" { + t.Fatalf("expected flock detail to be present, got %+v", createResp.Data.Flock) + } + if createResp.Data.Area.Id != areaID || createResp.Data.Area.Name == "" { + t.Fatalf("expected area detail to be present, got %+v", createResp.Data.Area) + } + if createResp.Data.Location.Id != locationID || createResp.Data.Location.Name == "" { + t.Fatalf("expected location detail to be present, got %+v", createResp.Data.Location) + } + if len(createResp.Data.Kandangs) != 1 || createResp.Data.Kandangs[0].Id != kandangID { + t.Fatalf("expected kandang detail to be present, got %+v", createResp.Data.Kandangs) + } + if createResp.Data.Kandangs[0].Status == "" { + t.Fatalf("expected kandang status to be present, got %+v", createResp.Data.Kandangs[0]) + } + if createResp.Data.Period != 1 { + t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) + } + + secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) + secondPayload := map[string]any{ + "flock_id": flockID, + "area_id": areaID, + "product_category_id": categoryID, + "fcr_id": fcrID, + "location_id": locationID, + "kandang_ids": []uint{secondKandangID}, + } + resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", secondPayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating second project flock, got %d: %s", resp.StatusCode, string(body)) + } + var createRespSecond struct { + Data struct { + Id uint `json:"id"` + Period int `json:"period"` + } `json:"data"` + } + if err := json.Unmarshal(body, &createRespSecond); err != nil { + t.Fatalf("failed to parse second create response: %v", err) + } + if createRespSecond.Data.Period != 2 { + t.Fatalf("expected second period to be 2, got %d", createRespSecond.Data.Period) + } + + resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching summary, got %d: %s", resp.StatusCode, string(body)) + } + + var summary struct { + Data struct { + NextPeriod int `json:"next_period"` + } `json:"data"` + } + if err := json.Unmarshal(body, &summary); err != nil { + t.Fatalf("failed to parse summary response: %v", err) + } + + if summary.Data.NextPeriod != 3 { + t.Fatalf("expected next_period 3, got %d", summary.Data.NextPeriod) + } + + resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createResp.Data.Id), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting first project flock, got %d: %s", resp.StatusCode, string(body)) + } + + resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createRespSecond.Data.Id), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body)) + } + + resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching summary after delete, got %d: %s", resp.StatusCode, string(body)) + } + + if err := json.Unmarshal(body, &summary); err != nil { + t.Fatalf("failed to parse summary response after delete: %v", err) + } + + if summary.Data.NextPeriod != 1 { + t.Fatalf("expected next_period 1 after soft deletes, got %d", summary.Data.NextPeriod) + } +} + +func uintToString(v uint) string { + return fmt.Sprintf("%d", v) +}