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/common/repository/common.approval.repository..go b/internal/common/repository/common.approval.repository..go new file mode 100644 index 00000000..7f1c27ae --- /dev/null +++ b/internal/common/repository/common.approval.repository..go @@ -0,0 +1,106 @@ +package repository + +import ( + "context" + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ApprovalRepository interface { + BaseRepository[entity.Approval] + FindByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error) + LatestByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error) + LatestByTargets(ctx context.Context, workflow string, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]entity.Approval, error) +} + +type approvalRepositoryImpl struct { + *BaseRepositoryImpl[entity.Approval] +} + +func NewApprovalRepository(db *gorm.DB) ApprovalRepository { + return &approvalRepositoryImpl{ + BaseRepositoryImpl: NewBaseRepository[entity.Approval](db), + } +} + +func (r *approvalRepositoryImpl) FindByTarget( + ctx context.Context, + workflow string, + approvableID uint, + modifier func(*gorm.DB) *gorm.DB, +) ([]entity.Approval, error) { + var approvals []entity.Approval + + q := r.DB().WithContext(ctx).Where("approvable_type = ? AND approvable_id = ?", workflow, approvableID) + if modifier != nil { + q = modifier(q) + } + + if err := q.Order("action_at ASC").Find(&approvals).Error; err != nil { + return nil, err + } + return approvals, nil +} + +func (r *approvalRepositoryImpl) LatestByTarget( + ctx context.Context, + workflow string, + approvableID uint, + modifier func(*gorm.DB) *gorm.DB, +) (*entity.Approval, error) { + var approval entity.Approval + + q := r.DB().WithContext(ctx). + Where("approvable_type = ? AND approvable_id = ?", workflow, approvableID). + Order("action_at DESC") + + if modifier != nil { + q = modifier(q) + } + + if err := q.Limit(1).First(&approval).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + return &approval, nil +} + +func (r *approvalRepositoryImpl) LatestByTargets( + ctx context.Context, + workflow string, + approvableIDs []uint, + modifier func(*gorm.DB) *gorm.DB, +) (map[uint]entity.Approval, error) { + if len(approvableIDs) == 0 { + return nil, nil + } + + result := make(map[uint]entity.Approval, len(approvableIDs)) + + q := r.DB().WithContext(ctx). + Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs). + Order("action_at DESC") + + if modifier != nil { + q = modifier(q) + } + + var approvals []entity.Approval + if err := q.Find(&approvals).Error; err != nil { + return nil, err + } + + for _, approval := range approvals { + if _, exists := result[approval.ApprovableId]; exists { + continue + } + result[approval.ApprovableId] = approval + } + + return result, nil +} diff --git a/internal/common/repository/repository.go b/internal/common/repository/common.base.repository.go similarity index 100% rename from internal/common/repository/repository.go rename to internal/common/repository/common.base.repository.go diff --git a/internal/common/repository/helpers.go b/internal/common/repository/common.exists.repository.go similarity index 100% rename from internal/common/repository/helpers.go rename to internal/common/repository/common.exists.repository.go diff --git a/internal/common/service/common.approval.service.go b/internal/common/service/common.approval.service.go new file mode 100644 index 00000000..569a7cc6 --- /dev/null +++ b/internal/common/service/common.approval.service.go @@ -0,0 +1,234 @@ +package service + +import ( + "context" + "strings" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + "gorm.io/gorm" +) + +type ApprovalService interface { + RegisterWorkflowSteps(workflow approvalutils.ApprovalWorkflowKey, steps map[approvalutils.ApprovalStep]string) error + WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string + WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool) + CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error) + List(ctx context.Context, module string, approvableID *uint, page, limit int, search string) ([]entity.Approval, int64, error) + ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error) + LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error) + LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error) +} + +type approvalService struct { + repo commonRepo.ApprovalRepository +} + +func NewApprovalService(repo commonRepo.ApprovalRepository) ApprovalService { + return &approvalService{repo: repo} +} + +func (s *approvalService) RegisterWorkflowSteps(workflow approvalutils.ApprovalWorkflowKey, steps map[approvalutils.ApprovalStep]string) error { + return approvalutils.RegisterWorkflowSteps(workflow, steps) +} + +func (s *approvalService) WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string { + return approvalutils.WorkflowSteps(workflow) +} + +func (s *approvalService) WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool) { + return approvalutils.ApprovalStepName(workflow, step) +} + +func (s *approvalService) CreateApproval( + ctx context.Context, + workflow approvalutils.ApprovalWorkflowKey, + approvableID uint, + step approvalutils.ApprovalStep, + action *entity.ApprovalAction, + actorID uint, + note *string, +) (*entity.Approval, error) { + record, err := approvalutils.NewApproval(workflow, approvableID, step, action, actorID, note) + if err != nil { + return nil, err + } + + if err := s.repo.CreateOne(ctx, record, nil); err != nil { + return nil, err + } + + s.decorateApproval(workflow, record) + + return record, nil +} + +func (s *approvalService) List( + ctx context.Context, + module string, + approvableID *uint, + page, limit int, + search string, +) ([]entity.Approval, int64, error) { + module = strings.TrimSpace(strings.ToUpper(module)) + search = strings.TrimSpace(search) + + if limit <= 0 { + limit = 10 + } + if page <= 0 { + page = 1 + } + + offset := (page - 1) * limit + + records, total, err := s.repo.GetAll( + ctx, + offset, + limit, + func(db *gorm.DB) *gorm.DB { + query := db. + Where("approvable_type = ?", module). + Order("action_at DESC"). + Preload("ActionUser") + + if approvableID != nil { + query = query.Where("approvable_id = ?", *approvableID) + } + + if search != "" { + like := "%" + strings.ToLower(search) + "%" + query = query.Where("(LOWER(step_name) LIKE ? OR LOWER(action) LIKE ? OR LOWER(notes) LIKE ?)", like, like, like) + } + + return query + }, + ) + if err != nil { + if s.isApprovalTableMissing(err) { + return nil, 0, nil + } + return nil, 0, err + } + if len(records) == 0 { + return nil, total, nil + } + + workflow := approvalutils.ApprovalWorkflowKey(module) + for i := range records { + s.decorateApproval(workflow, &records[i]) + } + + return records, total, nil +} + +func (s *approvalService) ListByTarget( + ctx context.Context, + workflow approvalutils.ApprovalWorkflowKey, + approvableID uint, + modifier func(*gorm.DB) *gorm.DB, +) ([]entity.Approval, error) { + records, err := s.repo.FindByTarget(ctx, workflow.String(), approvableID, modifier) + if err != nil { + if s.isApprovalTableMissing(err) { + return nil, nil + } + return nil, err + } + + for i := range records { + s.decorateApproval(workflow, &records[i]) + } + + return records, nil +} + +func (s *approvalService) LatestByTarget( + ctx context.Context, + workflow approvalutils.ApprovalWorkflowKey, + approvableID uint, + modifier func(*gorm.DB) *gorm.DB, +) (*entity.Approval, error) { + record, err := s.repo.LatestByTarget(ctx, workflow.String(), approvableID, modifier) + if err != nil { + if s.isApprovalTableMissing(err) { + return nil, nil + } + return nil, err + } + if record == nil { + return nil, nil + } + s.decorateApproval(workflow, record) + return record, nil +} + +func (s *approvalService) LatestByTargets( + ctx context.Context, + workflow approvalutils.ApprovalWorkflowKey, + approvableIDs []uint, + modifier func(*gorm.DB) *gorm.DB, +) (map[uint]*entity.Approval, error) { + records, err := s.repo.LatestByTargets(ctx, workflow.String(), approvableIDs, modifier) + if err != nil { + if s.isApprovalTableMissing(err) { + return nil, nil + } + return nil, err + } + if len(records) == 0 { + return nil, nil + } + + result := make(map[uint]*entity.Approval, len(records)) + for approvableID, approval := range records { + approvalCopy := approval + s.decorateApproval(workflow, &approvalCopy) + result[approvableID] = &approvalCopy + } + + return result, nil +} + +func (s *approvalService) decorateApproval(workflow approvalutils.ApprovalWorkflowKey, approval *entity.Approval) { + if approval == nil { + return + } + currentName := strings.TrimSpace(approval.StepName) + if currentName == "" { + if name, ok := approvalutils.ApprovalStepName(workflow, approvalutils.ApprovalStep(approval.StepNumber)); ok { + approval.StepName = name + } + } else { + approval.StepName = currentName + } +} + +func (s *approvalService) isApprovalTableMissing(err error) bool { + if err == nil { + return false + } + + errMsg := strings.ToLower(err.Error()) + + if strings.Contains(errMsg, "no such table: approvals") { + return true + } + + schemaIssues := []string{ + `relation "approvals" does not exist`, + `column "step_name" does not exist`, + `column "step_number" does not exist`, + `column "action" does not exist`, + `column "status" does not exist`, + `column "step" does not exist`, + } + for _, issue := range schemaIssues { + if strings.Contains(errMsg, issue) { + return true + } + } + + return false +} diff --git a/internal/common/service/relation.go b/internal/common/service/common.relation.service.go similarity index 100% rename from internal/common/service/relation.go rename to internal/common/service/common.relation.service.go diff --git a/internal/common/validation/custom_validation.go b/internal/common/validation/common.custom.validation.go similarity index 100% rename from internal/common/validation/custom_validation.go rename to internal/common/validation/common.custom.validation.go diff --git a/internal/database/migrations/20250925040409_create_master_tables.up.sql b/internal/database/migrations/20250925040409_create_master_tables.up.sql index 07e3005a..09b1c46e 100644 --- a/internal/database/migrations/20250925040409_create_master_tables.up.sql +++ b/internal/database/migrations/20250925040409_create_master_tables.up.sql @@ -316,7 +316,7 @@ CREATE TABLE stock_logs ( before_quantity NUMERIC(15, 3) NOT NULL, after_quantity NUMERIC(15, 3) NOT NULL, log_type VARCHAR(50) NOT NULL, - log_id BIGINT , + log_id BIGINT, note TEXT, product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE, created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, diff --git a/internal/database/migrations/20251014024355_create_stock_transfers.down.sql b/internal/database/migrations/20251014024355_create_stock_transfers.down.sql new file mode 100644 index 00000000..c2d70451 --- /dev/null +++ b/internal/database/migrations/20251014024355_create_stock_transfers.down.sql @@ -0,0 +1,4 @@ +-- DROP TABLE: STOCK_TRANSFERS DAN SEQUENCE-NYA +DROP TABLE IF EXISTS stock_transfers CASCADE; + +DROP SEQUENCE IF EXISTS stock_transfer_seq CASCADE; \ No newline at end of file diff --git a/internal/database/migrations/20251014024355_create_stock_transfers.up.sql b/internal/database/migrations/20251014024355_create_stock_transfers.up.sql new file mode 100644 index 00000000..766afe77 --- /dev/null +++ b/internal/database/migrations/20251014024355_create_stock_transfers.up.sql @@ -0,0 +1,57 @@ +-- =============================================================== +-- STOCK TRANSFERS (HEADER) +-- =============================================================== + +CREATE SEQUENCE IF NOT EXISTS stock_transfer_seq START 1; + +CREATE TABLE IF NOT EXISTS stock_transfers ( + id BIGSERIAL PRIMARY KEY, + movement_number VARCHAR(50) UNIQUE NOT NULL, + from_warehouse_id BIGINT NOT NULL, + to_warehouse_id BIGINT NOT NULL, + area_id BIGINT, + reason TEXT, + transfer_date DATE NOT NULL, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'warehouses') THEN + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_from_warehouse + FOREIGN KEY (from_warehouse_id) + REFERENCES warehouses(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_to_warehouse + FOREIGN KEY (to_warehouse_id) + REFERENCES warehouses(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'areas') THEN + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_area + FOREIGN KEY (area_id) + REFERENCES areas(id) + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_stock_transfers_from_warehouse_id ON stock_transfers(from_warehouse_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfers_to_warehouse_id ON stock_transfers(to_warehouse_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfers_transfer_date ON stock_transfers(transfer_date); diff --git a/internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql b/internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql new file mode 100644 index 00000000..64c0c8ed --- /dev/null +++ b/internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql @@ -0,0 +1,2 @@ +-- DROP TABLE: STOCK_TRANSFER_DETAILS +DROP TABLE IF EXISTS stock_transfer_details CASCADE; diff --git a/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql new file mode 100644 index 00000000..090014ff --- /dev/null +++ b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql @@ -0,0 +1,48 @@ +-- =============================================================== +-- STOCK TRANSFER DETAILS (PRODUK) +-- =============================================================== + +CREATE TABLE IF NOT EXISTS stock_transfer_details ( + id BIGSERIAL PRIMARY KEY, + stock_transfer_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0), + before_quantity NUMERIC(15, 3), + after_quantity NUMERIC(15, 3), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- =============================================================== +-- FOREIGN KEYS (dengan pengecekan tabel agar anti gagal) +-- =============================================================== + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfers') THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_transfer + FOREIGN KEY (stock_transfer_id) + REFERENCES stock_transfers(id) + ON DELETE CASCADE ON UPDATE CASCADE'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'products') THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_product + FOREIGN KEY (product_id) + REFERENCES products(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; +END $$; + +-- =============================================================== +-- INDEXES +-- =============================================================== + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_transfer_id ON stock_transfer_details (stock_transfer_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_product_id ON stock_transfer_details (product_id); \ No newline at end of file diff --git a/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql new file mode 100644 index 00000000..5167737f --- /dev/null +++ b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql @@ -0,0 +1,2 @@ +-- DROP TABLE: STOCK_TRANSFER_DELIVERIES +DROP TABLE IF EXISTS stock_transfer_deliveries CASCADE; diff --git a/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql new file mode 100644 index 00000000..52e5b5c2 --- /dev/null +++ b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql @@ -0,0 +1,42 @@ +-- =============================================================== +-- STOCK TRANSFER DELIVERIES (EKSPEDISI) +-- =============================================================== + +CREATE TABLE IF NOT EXISTS stock_transfer_deliveries ( + id BIGSERIAL PRIMARY KEY, + stock_transfer_id BIGINT NOT NULL, + supplier_id BIGINT, + vehicle_plate VARCHAR(20), + driver_name VARCHAR(100), + document_number VARCHAR(50), + document_path TEXT, + shipping_cost_item NUMERIC(15,3), + shipping_cost_total NUMERIC(15,3), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- FOREIGN KEYS +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfers') THEN + ALTER TABLE stock_transfer_deliveries + ADD CONSTRAINT fk_stock_transfer_deliveries_transfer + FOREIGN KEY (stock_transfer_id) + REFERENCES stock_transfers(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'suppliers') THEN + ALTER TABLE stock_transfer_deliveries + ADD CONSTRAINT fk_stock_transfer_deliveries_supplier + FOREIGN KEY (supplier_id) + REFERENCES suppliers(id) + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_stock_transfer_deliveries_transfer_id ON stock_transfer_deliveries(stock_transfer_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfer_deliveries_supplier_id ON stock_transfer_deliveries(supplier_id); diff --git a/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql new file mode 100644 index 00000000..15e1253d --- /dev/null +++ b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql @@ -0,0 +1,2 @@ +-- DROP PIVOT TABLE: STOCK_TRANSFER_DELIVERY_ITEMS +DROP TABLE IF EXISTS stock_transfer_delivery_items CASCADE; diff --git a/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql new file mode 100644 index 00000000..cb4c7a11 --- /dev/null +++ b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql @@ -0,0 +1,35 @@ +-- =============================================================== +-- STOCK TRANSFER DELIVERY ITEMS (PIVOT) +-- =============================================================== + +CREATE TABLE IF NOT EXISTS stock_transfer_delivery_items ( + id BIGSERIAL PRIMARY KEY, + stock_transfer_delivery_id BIGINT NOT NULL, + stock_transfer_detail_id BIGINT NOT NULL, + quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0) +); + +-- FOREIGN KEYS +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfer_deliveries') THEN + ALTER TABLE stock_transfer_delivery_items + ADD CONSTRAINT fk_delivery_items_delivery + FOREIGN KEY (stock_transfer_delivery_id) + REFERENCES stock_transfer_deliveries(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfer_details') THEN + ALTER TABLE stock_transfer_delivery_items + ADD CONSTRAINT fk_delivery_items_detail + FOREIGN KEY (stock_transfer_detail_id) + REFERENCES stock_transfer_details(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_stock_transfer_delivery_items_delivery_id ON stock_transfer_delivery_items (stock_transfer_delivery_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_delivery_items_detail_id ON stock_transfer_delivery_items (stock_transfer_detail_id); \ No newline at end of file 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/20251015162158_create_approvals_table.down.sql b/internal/database/migrations/20251015162158_create_approvals_table.down.sql new file mode 100644 index 00000000..0ad38d2b --- /dev/null +++ b/internal/database/migrations/20251015162158_create_approvals_table.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS approvals_approvable_lookup; +DROP TABLE IF EXISTS approvals; diff --git a/internal/database/migrations/20251015162158_create_approvals_table.up.sql b/internal/database/migrations/20251015162158_create_approvals_table.up.sql new file mode 100644 index 00000000..50154f33 --- /dev/null +++ b/internal/database/migrations/20251015162158_create_approvals_table.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE approvals ( + id BIGSERIAL PRIMARY KEY, + approvable_type VARCHAR(50) NOT NULL, + approvable_id BIGINT NOT NULL, + step SMALLINT NOT NULL, + status VARCHAR(20) NOT NULL, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + action_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +CREATE INDEX approvals_approvable_lookup ON approvals (approvable_type, approvable_id); diff --git a/internal/database/migrations/20251017071755_rename_approval_status_to_action.down.sql b/internal/database/migrations/20251017071755_rename_approval_status_to_action.down.sql new file mode 100644 index 00000000..cca2f08b --- /dev/null +++ b/internal/database/migrations/20251017071755_rename_approval_status_to_action.down.sql @@ -0,0 +1,18 @@ +ALTER TABLE approvals + RENAME COLUMN action TO status; + +UPDATE approvals +SET status = 'PENDING' +WHERE status IS NULL; + +ALTER TABLE approvals + ALTER COLUMN status SET NOT NULL; + +ALTER TABLE approvals + RENAME COLUMN step_number TO step; + +ALTER TABLE approvals + DROP COLUMN step_name; + +ALTER TABLE approvals + RENAME COLUMN action_at TO created_at; diff --git a/internal/database/migrations/20251017071755_rename_approval_status_to_action.up.sql b/internal/database/migrations/20251017071755_rename_approval_status_to_action.up.sql new file mode 100644 index 00000000..4d27cd27 --- /dev/null +++ b/internal/database/migrations/20251017071755_rename_approval_status_to_action.up.sql @@ -0,0 +1,14 @@ +ALTER TABLE approvals + RENAME COLUMN status TO action; + +ALTER TABLE approvals + ALTER COLUMN action DROP NOT NULL; + +ALTER TABLE approvals + RENAME COLUMN step TO step_number; + +ALTER TABLE approvals + ADD COLUMN step_name VARCHAR NOT NULL; + +ALTER TABLE approvals + RENAME COLUMN created_at TO action_at; diff --git a/internal/database/migrations/20251018072532_project_flock_kandangs.down.sql b/internal/database/migrations/20251018072532_project_flock_kandangs.down.sql new file mode 100644 index 00000000..fe912389 --- /dev/null +++ b/internal/database/migrations/20251018072532_project_flock_kandangs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_flock_kandangs; diff --git a/internal/database/migrations/20251018072532_project_flock_kandangs.up.sql b/internal/database/migrations/20251018072532_project_flock_kandangs.up.sql new file mode 100644 index 00000000..aba14be3 --- /dev/null +++ b/internal/database/migrations/20251018072532_project_flock_kandangs.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE project_flock_kandangs ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL REFERENCES project_flocks (id) ON DELETE CASCADE ON UPDATE CASCADE, + kandang_id BIGINT NOT NULL REFERENCES kandangs (id) ON DELETE CASCADE ON UPDATE CASCADE, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + detached_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_project_flock_kandangs_project ON project_flock_kandangs (project_flock_id); +CREATE INDEX idx_project_flock_kandangs_kandang ON project_flock_kandangs (kandang_id); + +CREATE UNIQUE INDEX idx_project_flock_kandangs_active ON project_flock_kandangs (project_flock_id, kandang_id) +WHERE + detached_at IS NULL; diff --git a/internal/database/migrations/20251018120649_create_project_chick_ins_table.down.sql b/internal/database/migrations/20251018120649_create_project_chick_ins_table.down.sql new file mode 100644 index 00000000..bb8f8a2d --- /dev/null +++ b/internal/database/migrations/20251018120649_create_project_chick_ins_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_chickins; \ No newline at end of file diff --git a/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql b/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql new file mode 100644 index 00000000..25d3476d --- /dev/null +++ b/internal/database/migrations/20251018120649_create_project_chick_ins_table.up.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS project_chickins ( + id BIGSERIAL PRIMARY KEY, + project_flock_kandang_id BIGINT NOT NULL, + chick_in_date DATE NOT NULL, + quantity NUMERIC(15, 3) NOT NULL, + note TEXT, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + ALTER TABLE project_chickins + ADD CONSTRAINT fk_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE project_chickins + ADD CONSTRAINT fk_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_project_chickins_project_flock_kandang_id ON project_chickins (project_flock_kandang_id); + +CREATE INDEX IF NOT EXISTS idx_project_chickins_created_by ON project_chickins (created_by); \ No newline at end of file diff --git a/internal/database/migrations/20251020022357_create_project_flock_populations_table.down.sql b/internal/database/migrations/20251020022357_create_project_flock_populations_table.down.sql new file mode 100644 index 00000000..8fa11576 --- /dev/null +++ b/internal/database/migrations/20251020022357_create_project_flock_populations_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_flock_populations; \ No newline at end of file diff --git a/internal/database/migrations/20251020022357_create_project_flock_populations_table.up.sql b/internal/database/migrations/20251020022357_create_project_flock_populations_table.up.sql new file mode 100644 index 00000000..82b3e9a7 --- /dev/null +++ b/internal/database/migrations/20251020022357_create_project_flock_populations_table.up.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS project_flock_populations ( + id BIGSERIAL PRIMARY KEY, + project_flock_kandang_id BIGINT NOT NULL, + initial_quantity NUMERIC(15, 3) NOT NULL, + current_quantity NUMERIC(15, 3) NOT NULL, + reserved_quantity NUMERIC(15, 3), + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + ALTER TABLE project_flock_populations + ADD CONSTRAINT fk_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE project_flock_populations + ADD CONSTRAINT fk_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_project_flock_populations_project_flock_kandang_id ON project_flock_populations (project_flock_kandang_id); + +CREATE INDEX IF NOT EXISTS idx_project_flock_populations_created_by ON project_flock_populations (created_by); \ No newline at end of file diff --git a/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.down.sql b/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.down.sql new file mode 100644 index 00000000..81c50f3f --- /dev/null +++ b/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.down.sql @@ -0,0 +1,25 @@ +BEGIN; + +-- Recreate legacy columns on project_flock_kandangs +DROP INDEX IF EXISTS idx_project_flock_kandangs_unique; + +ALTER TABLE project_flock_kandangs + ADD COLUMN IF NOT EXISTS created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, + ADD COLUMN IF NOT EXISTS assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_project_flock_kandangs_active + ON project_flock_kandangs (project_flock_id, kandang_id) + WHERE detached_at IS NULL; + +-- Restore product_category_id reference and drop category column +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS product_category_id BIGINT REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS category; + +COMMIT; + +DROP INDEX IF EXISTS project_flocks_flock_period_unique; diff --git a/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.up.sql b/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.up.sql new file mode 100644 index 00000000..2341a4cd --- /dev/null +++ b/internal/database/migrations/20251020154311_adjustment_projectFlock_category_and_period_unique.up.sql @@ -0,0 +1,43 @@ +BEGIN; + +-- Add category column to project_flocks and backfill existing rows +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS category VARCHAR(20); + +UPDATE project_flocks +SET category = 'GROWING' +WHERE category IS NULL; + +ALTER TABLE project_flocks + ALTER COLUMN category SET NOT NULL; + +ALTER TABLE project_flocks + ALTER COLUMN category SET DEFAULT 'GROWING'; + +-- Drop legacy foreign key reference and column +ALTER TABLE project_flocks + DROP CONSTRAINT IF EXISTS project_flocks_product_category_id_fkey; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS product_category_id; + +-- Simplify project_flock_kandangs structure +DROP INDEX IF EXISTS idx_project_flock_kandangs_active; + +ALTER TABLE project_flock_kandangs + DROP COLUMN IF EXISTS created_by, + DROP COLUMN IF EXISTS assigned_at, + DROP COLUMN IF EXISTS detached_at, + DROP COLUMN IF EXISTS updated_at; + +ALTER TABLE project_flock_kandangs + ALTER COLUMN created_at SET DEFAULT NOW(); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_project_flock_kandangs_unique + ON project_flock_kandangs (project_flock_id, kandang_id); + +COMMIT; + +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..32c3b310 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -8,6 +8,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gorm.io/gorm" ) @@ -35,7 +36,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, fcrs, locations) + if err != nil { + return err + } + + kandangs, err := seedKandangs(tx, adminID, locations, users, projectFlocks) if err != nil { return err } @@ -44,11 +65,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 +74,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 } @@ -78,6 +90,13 @@ func Run(db *gorm.DB) error { return err } + if err := seedTransferStock(tx, adminID); err != nil { + return err + } + if err := seedChickin(tx, adminID); err != nil { + return err + } + fmt.Println("✅ Master data seeding completed") return nil }) @@ -194,16 +213,190 @@ 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, fcrs, locations map[string]uint) (map[string]uint, error) { seeds := []struct { - Name string + Key string + Flock string + Area string + Category utils.ProjectFlockCategory + Fcr string Location string - PicKey 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", + Category: utils.ProjectFlockCategoryGrowing, + Fcr: "FCR Layer", + Location: "Singaparna", + Period: 1, + }, + { + Key: "Cikaum Period 1", + Flock: "Flock Banten", + Area: "Banten", + Category: utils.ProjectFlockCategoryGrowing, + 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) + } + 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 category = ? AND fcr_id = ? AND location_id = ? AND period = ?", + flockID, areaID, seed.Category, fcrID, locationID, seed.Period).First(&projectFlock).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + projectFlock = entity.ProjectFlock{ + FlockId: flockID, + AreaId: areaID, + Category: string(seed.Category), + 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, + "category": string(seed.Category), + "fcr_id": fcrID, + "location_id": locationID, + "period": seed.Period, + }).Error; err != nil { + return nil, err + } + } + + if err := ensureProjectFlockApprovals(tx, projectFlock.Id, createdBy); err != nil { + return nil, err + } + result[seed.Key] = projectFlock.Id + } + + return result, nil +} + +func ensureProjectFlockApprovals(tx *gorm.DB, projectFlockID uint, actorID uint) error { + if projectFlockID == 0 || actorID == 0 { + return nil + } + + workflow := utils.ApprovalWorkflowProjectFlock.String() + + steps := []struct { + step approvalutils.ApprovalStep + action entity.ApprovalAction + }{ + {step: utils.ProjectFlockStepPengajuan, action: entity.ApprovalActionCreated}, + {step: utils.ProjectFlockStepAktif, action: entity.ApprovalActionApproved}, + } + + for _, cfg := range steps { + var count int64 + if err := tx.Model(&entity.Approval{}). + Where("approvable_type = ? AND approvable_id = ? AND step_number = ?", workflow, projectFlockID, uint16(cfg.step)). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + continue + } + + stepName, ok := utils.ProjectFlockApprovalSteps[cfg.step] + if !ok || strings.TrimSpace(stepName) == "" { + stepName = fmt.Sprintf("Step %d", cfg.step) + } + + var actionPtr *entity.ApprovalAction + action := cfg.action + actionPtr = &action + + record := entity.Approval{ + ApprovableType: workflow, + ApprovableId: projectFlockID, + StepNumber: uint16(cfg.step), + StepName: stepName, + Action: actionPtr, + ActionBy: uintPtr(actorID), + } + + if err := tx.Create(&record).Error; err != nil { + return err + } + } + + return 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"}, + {Name: "Cikaum 1", Status: utils.KandangStatusActive, Location: "Cikaum", PicKey: "admin", ProjectFlockKey: strPtr("Cikaum Period 1")}, + {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, } result := make(map[string]uint, len(seeds)) @@ -218,20 +411,51 @@ 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 } + if err := syncPivotRelation(tx, projectFlockID, kandang.Id); 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 + } + if err := syncPivotRelation(tx, projectFlockID, kandang.Id); err != nil { + return nil, err + } } result[seed.Name] = kandang.Id } @@ -239,6 +463,38 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return result, nil } +func syncPivotRelation(tx *gorm.DB, projectFlockID *uint, kandangID uint) error { + if err := detachActivePivot(tx, kandangID); err != nil { + return err + } + if projectFlockID == nil { + return nil + } + return ensureActivePivot(tx, *projectFlockID, kandangID) +} + +func detachActivePivot(tx *gorm.DB, kandangID uint) error { + return tx.Where("kandang_id = ?", kandangID). + Delete(&entity.ProjectFlockKandang{}).Error +} + +func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID uint) error { + var pivot entity.ProjectFlockKandang + err := tx.Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). + First(&pivot).Error + if err == nil { + return nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + newRecord := entity.ProjectFlockKandang{ + ProjectFlockId: projectFlockID, + KandangId: kandangID, + } + return tx.Create(&newRecord).Error +} + func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { seeds := []struct { Name string @@ -430,7 +686,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 +708,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 +734,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 { @@ -775,7 +1034,8 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { }{ {ProductID: 1, WarehouseID: 1, Quantity: 100}, {ProductID: 2, WarehouseID: 2, Quantity: 200}, - {ProductID: 1, WarehouseID: 1, Quantity: 300}, + {ProductID: 2, WarehouseID: 1, Quantity: 300}, + {ProductID: 1, WarehouseID: 3, Quantity: 5000}, } for _, seed := range seeds { @@ -799,6 +1059,150 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { return nil } +func seedTransferStock(tx *gorm.DB, createdBy uint) error { + + transfer := entity.StockTransfer{ + FromWarehouseId: 1, + ToWarehouseId: 2, + Reason: "Seed transfer stock", + TransferDate: time.Now(), + MovementNumber: "SEED-TRF-00001", + CreatedBy: 1, + } + if err := tx.Create(&transfer).Error; err != nil { + return err + } + + details := []entity.StockTransferDetail{ + { + StockTransferId: transfer.Id, + ProductId: 1, + Quantity: 10, + }, + { + StockTransferId: transfer.Id, + ProductId: 2, + Quantity: 5, + }, + } + for i := range details { + if err := tx.Create(&details[i]).Error; err != nil { + return err + } + } + + deliveries := []entity.StockTransferDelivery{ + { + StockTransferId: transfer.Id, + SupplierId: 1, + VehiclePlate: "B 1234 XYZ", + DriverName: "Driver Seed", + DocumentPath: "seed.pdf", + ShippingCostItem: 1000, + ShippingCostTotal: 2000, + }, + } + for i := range deliveries { + if err := tx.Create(&deliveries[i]).Error; err != nil { + return err + } + } + + detailMap := make(map[uint64]uint64) + for _, d := range details { + detailMap[d.ProductId] = d.Id + } + + deliveryItems := []entity.StockTransferDeliveryItem{ + { + StockTransferDeliveryId: deliveries[0].Id, + StockTransferDetailId: detailMap[1], + Quantity: 50, + }, + { + StockTransferDeliveryId: deliveries[0].Id, + StockTransferDetailId: detailMap[2], + Quantity: 30, + }, + } + for i := range deliveryItems { + if err := tx.Create(&deliveryItems[i]).Error; err != nil { + return err + } + } + + return nil +} + +func seedChickin(tx *gorm.DB, createdBy uint) error { + seeds := []struct { + ProjectFlockKandangId uint + ChickInDate string + Quantity float64 + Note string + }{ + {ProjectFlockKandangId: 1, ChickInDate: "2025-10-20", Quantity: 100, Note: "Seeder chickin 1"}, + {ProjectFlockKandangId: 2, ChickInDate: "2025-10-21", Quantity: 200, Note: "Seeder chickin 2"}, + } + + for _, seed := range seeds { + chickinDate, err := time.Parse("2006-01-02", seed.ChickInDate) + if err != nil { + return err + } + + // Insert ProjectChickin jika belum ada + var chickin entity.ProjectChickin + err = tx.Where("project_flock_kandang_id = ? AND chick_in_date = ?", seed.ProjectFlockKandangId, chickinDate). + First(&chickin).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + chickin = entity.ProjectChickin{ + ProjectFlockKandangId: seed.ProjectFlockKandangId, + ChickInDate: chickinDate, + Quantity: seed.Quantity, + Note: seed.Note, + CreatedBy: createdBy, + } + if err := tx.Create(&chickin).Error; err != nil { + return err + } + } else if err != nil { + return err + } + + // Update/Insert ProjectFlockPopulation + var population entity.ProjectFlockPopulation + err = tx.Where("project_flock_kandang_id = ?", seed.ProjectFlockKandangId).First(&population).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + population = entity.ProjectFlockPopulation{ + ProjectFlockKandangId: seed.ProjectFlockKandangId, + InitialQuantity: seed.Quantity, + CurrentQuantity: seed.Quantity, + ReservedQuantity: 0, + CreatedBy: createdBy, + } + if err := tx.Create(&population).Error; err != nil { + return err + } + } else if err != nil { + return err + } else { + // Update population quantities + if err := tx.Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", population.Id). + Updates(map[string]any{ + "initial_quantity": population.InitialQuantity + seed.Quantity, + "current_quantity": population.CurrentQuantity + seed.Quantity, + "reserved_quantity": 0, + }).Error; err != nil { + return err + } + } + } + + return nil +} + func ptr[T any](v T) *T { return &v } diff --git a/internal/entities/approval.go b/internal/entities/approval.go new file mode 100644 index 00000000..87dc7b0a --- /dev/null +++ b/internal/entities/approval.go @@ -0,0 +1,28 @@ +package entities + +import ( + "time" +) + +type ApprovalAction string + +const ( + ApprovalActionApproved ApprovalAction = "APPROVED" + ApprovalActionRejected ApprovalAction = "REJECTED" + ApprovalActionCreated ApprovalAction = "CREATED" + ApprovalActionUpdated ApprovalAction = "UPDATED" +) + +type Approval struct { + Id uint `gorm:"primaryKey"` + ApprovableType string `gorm:"size:50;not null;index:approvals_approvable_lookup,priority:1"` + ApprovableId uint `gorm:"not null;index:approvals_approvable_lookup,priority:2"` + StepNumber uint16 `gorm:"not null"` + StepName string `gorm:"not null"` + Action *ApprovalAction `gorm:"type:VARCHAR(20)"` + Notes *string `gorm:"type:text"` + ActionAt time.Time `gorm:"autoCreateTime"` + ActionBy *uint `gorm:"index"` + + ActionUser *User `gorm:"foreignKey:ActionBy;references:Id"` +} diff --git a/internal/entities/audit_log.go b/internal/entities/audit_log.go new file mode 100644 index 00000000..3b770125 --- /dev/null +++ b/internal/entities/audit_log.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" +) + +type AuditLog struct { + Id uint `gorm:"primaryKey"` + TableName string `gorm:"size:100;not null"` + RecordId uint `gorm:"not null"` + Action string `gorm:"size:30;not null"` + BeforeData string `gorm:"type:jsonb"` + AfterData string `gorm:"type:jsonb"` + ChangedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + + User *User `gorm:"foreignKey:ChangedBy;references:Id"` +} 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/project_chickin.go b/internal/entities/project_chickin.go new file mode 100644 index 00000000..95a658c8 --- /dev/null +++ b/internal/entities/project_chickin.go @@ -0,0 +1,24 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +const () + +type ProjectChickin struct { + Id uint `gorm:"primaryKey"` + ProjectFlockKandangId uint `gorm:"not null"` + ChickInDate time.Time `gorm:"not null"` + Quantity float64 `gorm:"not null"` + Note string `gorm:"type:text"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/project_flock_population.go b/internal/entities/project_flock_population.go new file mode 100644 index 00000000..184ace65 --- /dev/null +++ b/internal/entities/project_flock_population.go @@ -0,0 +1,22 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ProjectFlockPopulation struct { + Id uint `gorm:"primaryKey"` + ProjectFlockKandangId uint `gorm:"not null"` + InitialQuantity float64 `gorm:"type:numeric(15,3);not null"` + CurrentQuantity float64 `gorm:"type:numeric(15,3);not null"` + ReservedQuantity float64 `gorm:"type:numeric(15,3)"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` + + ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go new file mode 100644 index 00000000..c840892f --- /dev/null +++ b/internal/entities/projectflock.go @@ -0,0 +1,30 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ProjectFlock struct { + Id uint `gorm:"primaryKey"` + FlockId uint `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` + AreaId uint `gorm:"not null"` + Category string `gorm:"type:varchar(20);not null"` + FcrId uint `gorm:"not null"` + LocationId uint `gorm:"not null"` + Period int `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` + 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"` + 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"` + KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go new file mode 100644 index 00000000..1c29c22e --- /dev/null +++ b/internal/entities/projectflock_kandang.go @@ -0,0 +1,12 @@ +package entities + +import "time" + +type ProjectFlockKandang struct { + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` + KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` + CreatedAt time.Time `gorm:"autoCreateTime"` + ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` + Kandang Kandang `gorm:"foreignKey:KandangId;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/entities/stock-transfer.go b/internal/entities/stock-transfer.go new file mode 100644 index 00000000..e003d601 --- /dev/null +++ b/internal/entities/stock-transfer.go @@ -0,0 +1,23 @@ +package entities + +import "time" + +// HEADER +type StockTransfer struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + MovementNumber string `gorm:"uniqueIndex;not null"` + FromWarehouseId uint64 + ToWarehouseId uint64 + TransferDate time.Time + Reason string + CreatedBy uint64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + FromWarehouse *Warehouse `gorm:"foreignKey:FromWarehouseId"` + ToWarehouse *Warehouse `gorm:"foreignKey:ToWarehouseId"` + Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` + Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` + CreatedUser *User `gorm:"foreignKey:CreatedBy"` +} diff --git a/internal/entities/stock_availabilites.go b/internal/entities/stock_availabilites.go new file mode 100644 index 00000000..ec24d36b --- /dev/null +++ b/internal/entities/stock_availabilites.go @@ -0,0 +1,26 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + EntityTypeProjectFlockKandang = "PROJECT_FLOCK_KANDANG" +) + +type StockAvailability struct { + Id uint `gorm:"primaryKey"` + EntityType string `gorm:"size:50;not null"` + EntityId uint `gorm:"not null"` + ProductId uint `gorm:"not null"` + Quantity float64 `gorm:"not null;default:0"` + ReservedQuantity float64 `gorm:"not null;default:0"` + Unit string `gorm:"size:20"` + LastUpdated time.Time `gorm:"autoUpdateTime"` + CreatedAt time.Time `gorm:"autoCreateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Product *Product `gorm:"foreignKey:ProductId;references:Id"` +} diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 21e86bd4..6546e790 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -8,6 +8,7 @@ import ( const ( LogTypeAdjustment = "ADJUSTMENT" + LogTypeTransfer = "TRANSFER" ) const ( diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go new file mode 100644 index 00000000..3a7562ea --- /dev/null +++ b/internal/entities/stock_transfer_delivery.go @@ -0,0 +1,23 @@ +package entities + +import "time" + +// DETAIL EKSPEDISI +type StockTransferDelivery struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + SupplierId uint64 + VehiclePlate string + DriverName string + DocumentNumber string + DocumentPath string + ShippingCostItem float64 + ShippingCostTotal float64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Supplier *Supplier `gorm:"foreignKey:SupplierId"` + Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` +} \ No newline at end of file diff --git a/internal/entities/stock_transfer_delivery_item.go b/internal/entities/stock_transfer_delivery_item.go new file mode 100644 index 00000000..cbfa05fb --- /dev/null +++ b/internal/entities/stock_transfer_delivery_item.go @@ -0,0 +1,12 @@ +package entities + +// PIVOT TABLE TRANSFER +type StockTransferDeliveryItem struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferDeliveryId uint64 + StockTransferDetailId uint64 + Quantity float64 + // Relations + StockTransferDelivery *StockTransferDelivery `gorm:"foreignKey:StockTransferDeliveryId"` + StockTransferDetail *StockTransferDetail `gorm:"foreignKey:StockTransferDetailId"` +} diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go new file mode 100644 index 00000000..253a3bf8 --- /dev/null +++ b/internal/entities/stock_transfer_detail.go @@ -0,0 +1,18 @@ +package entities + +import "time" + +// DETAIL PRODUK +type StockTransferDetail struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + ProductId uint64 + Quantity float64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Product *Product `gorm:"foreignKey:ProductId"` + DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` +} 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/approvals/controllers/approval.controller.go b/internal/modules/approvals/controllers/approval.controller.go new file mode 100644 index 00000000..fd0baa6e --- /dev/null +++ b/internal/modules/approvals/controllers/approval.controller.go @@ -0,0 +1,100 @@ +package controller + +import ( + "math" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" +) + +type ApprovalController struct { + ApprovalService common.ApprovalService +} + +func NewApprovalController(approvalService common.ApprovalService) *ApprovalController { + return &ApprovalController{ + ApprovalService: approvalService, + } +} + +func (u *ApprovalController) GetAll(c *fiber.Ctx) error { + moduleName := strings.TrimSpace(c.Query("module_name", "")) + if moduleName == "" { + return fiber.NewError(fiber.StatusBadRequest, "`module_name` is required") + } + + moduleIDParam := strings.TrimSpace(c.Query("module_id", "")) + var moduleID *uint + if moduleIDParam != "" { + value, err := strconv.ParseUint(moduleIDParam, 10, 64) + if err != nil || value == 0 { + return fiber.NewError(fiber.StatusBadRequest, "module_id must be a positive integer") + } + id := uint(value) + moduleID = &id + } + + groupByStep := c.QueryBool("group_step_number", false) + + page := c.QueryInt("page", 1) + limit := c.QueryInt("limit", 10) + search := strings.TrimSpace(c.Query("search", "")) + + query := &validation.Query{ + ModuleName: moduleName, + ModuleId: moduleID, + GroupByStep: groupByStep, + Page: page, + Limit: limit, + Search: search, + } + + records, totalResults, err := u.ApprovalService.List( + c.Context(), + query.ModuleName, + query.ModuleId, + query.Page, + query.Limit, + query.Search, + ) + if err != nil { + return err + } + + if query.GroupByStep { + data := dto.ToApprovalGroupDTOs(records) + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ApprovalGroupDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get All approvals successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: data, + }) + } + + flat := dto.ToApprovalDTOs(records) + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ApprovalBaseDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get All approvals successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: flat, + }) +} diff --git a/internal/modules/approvals/dto/approval.dto.go b/internal/modules/approvals/dto/approval.dto.go new file mode 100644 index 00000000..085c367c --- /dev/null +++ b/internal/modules/approvals/dto/approval.dto.go @@ -0,0 +1,122 @@ +package dto + +import ( + "sort" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" +) + +type ApprovalBaseDTO struct { + StepNumber uint16 `json:"step_number"` + StepName string `json:"step_name"` + Action *string `json:"action"` + Notes *string `json:"notes"` + ActionBy userDTO.UserBaseDTO `json:"action_by"` + ActionAt time.Time `json:"action_at"` +} + +type ApprovalGroupDTO struct { + StepNumber uint16 `json:"step_number"` + StepName string `json:"step_name"` + Approvals []ApprovalBaseDTO `json:"approvals"` +} + +func ToApprovalDTO(e entity.Approval) ApprovalBaseDTO { + dto := ApprovalBaseDTO{ + Notes: e.Notes, + } + + if e.StepNumber > 0 { + stepCopy := uint16(e.StepNumber) + dto.StepNumber = stepCopy + } + + stepName := strings.TrimSpace(e.StepName) + if stepName == "" && e.ApprovableType != "" && e.StepNumber > 0 { + if label, ok := approvalutils.ApprovalStepName(approvalutils.ApprovalWorkflowKey(e.ApprovableType), approvalutils.ApprovalStep(e.StepNumber)); ok { + stepName = label + } + } + dto.StepName = stepName + + if e.Action != nil { + value := strings.TrimSpace(string(*e.Action)) + if value != "" { + valueCopy := value + dto.Action = &valueCopy + } + } + + if e.ActionUser != nil && e.ActionUser.Id != 0 { + user := userDTO.ToUserBaseDTO(*e.ActionUser) + dto.ActionBy = user + } else if e.ActionBy != nil && *e.ActionBy != 0 { + dto.ActionBy = userDTO.UserBaseDTO{ + Id: *e.ActionBy, + IdUser: int64(*e.ActionBy), + } + } + + if !e.ActionAt.IsZero() { + at := e.ActionAt + dto.ActionAt = at + } + + return dto +} + +func ToApprovalDTOs(items []entity.Approval) []ApprovalBaseDTO { + result := make([]ApprovalBaseDTO, len(items)) + for i, item := range items { + result[i] = ToApprovalDTO(item) + } + return result +} + +func ToApprovalGroupDTOs(items []entity.Approval) []ApprovalGroupDTO { + if len(items) == 0 { + return nil + } + + type groupAccumulator struct { + StepName string + Approvals []ApprovalBaseDTO + } + + groups := make(map[uint16]*groupAccumulator) + order := make([]uint16, 0) + for _, item := range items { + step := item.StepNumber + acc, exists := groups[step] + if !exists { + stepName := strings.TrimSpace(item.StepName) + if stepName == "" && item.ApprovableType != "" && item.StepNumber > 0 { + if label, ok := approvalutils.ApprovalStepName(approvalutils.ApprovalWorkflowKey(item.ApprovableType), approvalutils.ApprovalStep(item.StepNumber)); ok { + stepName = label + } + } + acc = &groupAccumulator{StepName: stepName} + groups[step] = acc + order = append(order, step) + } + acc.Approvals = append(acc.Approvals, ToApprovalDTO(item)) + } + + sort.Slice(order, func(i, j int) bool { return order[i] < order[j] }) + + result := make([]ApprovalGroupDTO, len(order)) + for i, step := range order { + acc := groups[step] + result[i] = ApprovalGroupDTO{ + StepNumber: step, + StepName: acc.StepName, + Approvals: acc.Approvals, + } + } + + return result +} diff --git a/internal/modules/approvals/module.go b/internal/modules/approvals/module.go new file mode 100644 index 00000000..8cf52f73 --- /dev/null +++ b/internal/modules/approvals/module.go @@ -0,0 +1,25 @@ +package approvals + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ApprovalModule struct{} + +func (ApprovalModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + approvalRepo := commonRepo.NewApprovalRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalService := commonSvc.NewApprovalService(approvalRepo) + userService := sUser.NewUserService(userRepo, validate) + + ApprovalRoutes(router, userService, approvalService) +} diff --git a/internal/modules/approvals/route.go b/internal/modules/approvals/route.go new file mode 100644 index 00000000..b7d66abd --- /dev/null +++ b/internal/modules/approvals/route.go @@ -0,0 +1,19 @@ +package approvals + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + "github.com/gofiber/fiber/v2" + + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/controllers" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +func ApprovalRoutes(v1 fiber.Router, u user.UserService, s common.ApprovalService) { + _ = u + ctrl := controller.NewApprovalController(s) + + route := v1.Group("/approvals") + + route.Get("/", ctrl.GetAll) +} diff --git a/internal/modules/approvals/validations/approval.validation.go b/internal/modules/approvals/validations/approval.validation.go new file mode 100644 index 00000000..7338550e --- /dev/null +++ b/internal/modules/approvals/validations/approval.validation.go @@ -0,0 +1,10 @@ +package validation + +type Query struct { + ModuleName string `json:"module_name" validate:"required_strict"` + ModuleId *uint `json:"module_id,omitempty" validate:"omitempty,gt=0"` + GroupByStep bool `json:"group_by_step"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/constants/repositories/constant.repository.go b/internal/modules/constants/repositories/constant.repository.go index 7b85ce20..4b44d553 100644 --- a/internal/modules/constants/repositories/constant.repository.go +++ b/internal/modules/constants/repositories/constant.repository.go @@ -1,9 +1,13 @@ package repository import ( + "sort" + "strconv" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gorm.io/gorm" ) @@ -26,6 +30,50 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { for f := range utils.AllFlagTypes() { flagList = append(flagList, string(f)) } + sort.Strings(flagList) + + type approvalStepConstant struct { + StepNumber uint16 `json:"step_number"` + StepName string `json:"step_name"` + } + + workflowConstants := approvalutils.WorkflowConstants() + workflowKeys := make([]string, 0, len(workflowConstants)) + for key := range workflowConstants { + workflowKeys = append(workflowKeys, key) + } + sort.Strings(workflowKeys) + + approvalWorkflows := make([]map[string]interface{}, 0, len(workflowKeys)) + for _, key := range workflowKeys { + stepMap := workflowConstants[key] + if len(stepMap) == 0 { + continue + } + + stepList := make([]approvalStepConstant, 0, len(stepMap)) + for stepStr, label := range stepMap { + stepNum, err := strconv.ParseUint(stepStr, 10, 16) + if err != nil || stepNum == 0 { + continue + } + stepList = append(stepList, approvalStepConstant{ + StepNumber: uint16(stepNum), + StepName: label, + }) + } + if len(stepList) == 0 { + continue + } + sort.Slice(stepList, func(i, j int) bool { + return stepList[i].StepNumber < stepList[j].StepNumber + }) + + approvalWorkflows = append(approvalWorkflows, map[string]interface{}{ + "key": key, + "steps": stepList, + }) + } return map[string]interface{}{ "flags": flagList, @@ -42,5 +90,6 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { "BISNIS", "INDIVIDUAL", }, + "approval_workflows": approvalWorkflows, } } diff --git a/internal/modules/inventory/adjustments/controllers/adjustment.controller.go b/internal/modules/inventory/adjustments/controllers/adjustment.controller.go index dc3df0a9..617a1b5f 100644 --- a/internal/modules/inventory/adjustments/controllers/adjustment.controller.go +++ b/internal/modules/inventory/adjustments/controllers/adjustment.controller.go @@ -49,8 +49,8 @@ func (u *AdjustmentController) AdjustmentHistory(c *fiber.Ctx) error { query := &validation.Query{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), - ProductID: c.QueryInt("product_id", 0), - WarehouseID: c.QueryInt("warehouse_id", 0), + ProductID: uint(c.QueryInt("product_id", 0)), + WarehouseID: uint(c.QueryInt("warehouse_id", 0)), TransactionType: c.Query("transaction_type", ""), } diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index cfe01118..b3e12676 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -9,7 +9,7 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" - rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 929a5c8a..7a2d06bc 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -4,12 +4,14 @@ import ( "errors" "strings" + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" - stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" + stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" @@ -77,22 +79,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } ctx := c.Context() - isProductExist, err := s.ProductRepo.IdExists(c.Context(), uint(req.ProductID)) - if err != nil { - s.Log.Errorf("Failed to check product existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") - } - if !isProductExist { - return nil, fiber.NewError(fiber.StatusBadRequest, "Product not found") - } - - isWarehouseExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(req.WarehouseID)) - if err != nil { - s.Log.Errorf("Failed to check warehouse existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") - } - if !isWarehouseExist { - return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not found") + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists}, + common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists}, + ); err != nil { + return nil, err } if req.Quantity <= 0 { @@ -118,6 +109,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e Quantity: 0, CreatedBy: 1, // TODO: should Get from auth middleware } + if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil { s.Log.Errorf("Failed to create product warehouse: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create product warehouse") @@ -126,7 +118,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) if err != nil { s.Log.Errorf("Failed to get product warehouse: %+v", err) @@ -159,14 +150,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e s.Log.Errorf("Failed to create stock log: %+v", err) return err } - s.Log.Infof("Stock log created: %+v", newLog.Id) productWarehouse.Quantity = afterQuantity if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) return err } - s.Log.Infof("Product warehouse quantity updated: %+v", productWarehouse.Id) createdLogId = newLog.Id return nil @@ -184,9 +173,26 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu if err := s.Validate.Struct(query); err != nil { return nil, 0, err } - offset := (query.Page - 1) * query.Limit + isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) + if err != nil { + s.Log.Errorf("Failed to check warehouse existence: %+v", err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") + } + if query.WarehouseID > 0 && !isWarehousesExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + + isProductsExist, err := s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) + if err != nil { + s.Log.Errorf("Failed to check product existence: %+v", err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") + } + if query.ProductID > 0 && !isProductsExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) diff --git a/internal/modules/inventory/adjustments/validations/adjustment.validation.go b/internal/modules/inventory/adjustments/validations/adjustment.validation.go index 7d2385cc..2e7259f2 100644 --- a/internal/modules/inventory/adjustments/validations/adjustment.validation.go +++ b/internal/modules/inventory/adjustments/validations/adjustment.validation.go @@ -11,7 +11,7 @@ type Create struct { type Query struct { Page int `query:"page" validate:"omitempty,min=1"` Limit int `query:"limit" validate:"omitempty,min=1,max=100"` - ProductID int `query:"product_id" validate:"omitempty,min=0"` - WarehouseID int `query:"warehouse_id" validate:"omitempty,min=0"` + ProductID uint `query:"product_id" validate:"omitempty,min=0"` + WarehouseID uint `query:"warehouse_id" validate:"omitempty,min=0"` TransactionType string `query:"transaction_type" validate:"omitempty,oneof=increase decrease"` } diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 2260e834..8c9f3846 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -4,25 +4,29 @@ 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 ProductWarehouseBaseDTO struct { - Id uint `json:"id"` - ProductId uint `json:"product_id"` - WarehouseId uint `json:"warehouse_id"` - Quantity float64 `json:"quantity"` + Id uint `json:"id"` + ProductId uint `json:"product_id"` + WarehouseId uint `json:"warehouse_id"` + Quantity float64 `json:"quantity"` } type ProductWarehouseListDTO struct { ProductWarehouseBaseDTO - Product *ProductBaseDTO `json:"product,omitempty"` - Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"` - CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Product *ProductBaseDTO `json:"product,omitempty"` + Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"` + CreatedUser *UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserBaseDTO struct { + Id uint `json:"id"` + Username string `json:"username"` } type ProductWarehouseDetailDTO struct { @@ -31,12 +35,31 @@ type ProductWarehouseDetailDTO struct { // Nested DTOs for relations type ProductBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Sku string `json:"sku"` + Id uint `json:"id"` + Name string `json:"name"` + Sku string `json:"sku"` + Flags []string `json:"flags"` } type WarehouseBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Kandang *KandangBaseDTO `json:"kandang,omitempty"` + Location *LocationBaseDTO `json:"location,omitempty"` + Area *AreaBaseDTO `json:"area,omitempty"` +} + +type KandangBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type AreaBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` } @@ -68,6 +91,11 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT if e.Product.Sku != nil { product.Sku = *e.Product.Sku } + if len(e.Product.Flags) > 0 { + for _, f := range e.Product.Flags { + product.Flags = append(product.Flags, f.Name) + } + } dto.Product = &product } @@ -77,12 +105,37 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT Id: e.Warehouse.Id, Name: e.Warehouse.Name, } + // Map Kandang jika ada + if e.Warehouse.Kandang != nil && e.Warehouse.Kandang.Id != 0 { + warehouse.Kandang = &KandangBaseDTO{ + Id: e.Warehouse.Kandang.Id, + Name: e.Warehouse.Kandang.Name, + } + } + // Map Location jika ada + if e.Warehouse.Location != nil && e.Warehouse.Location.Id != 0 { + warehouse.Location = &LocationBaseDTO{ + Id: e.Warehouse.Location.Id, + Name: e.Warehouse.Location.Name, + } + } + + if &e.Warehouse.Area != nil && e.Warehouse.Area.Id != 0 { + warehouse.Area = &AreaBaseDTO{ + Id: e.Warehouse.Area.Id, + Name: e.Warehouse.Area.Name, + } + } + dto.Warehouse = &warehouse } // Map CreatedUser relation jika ada if e.CreatedUser.Id != 0 { - user := userDTO.ToUserBaseDTO(e.CreatedUser) + user := UserBaseDTO{ + Id: e.CreatedUser.Id, + Username: e.CreatedUser.Name, + } dto.CreatedUser = &user } @@ -102,3 +155,24 @@ func ToProductWarehouseDetailDTO(e entity.ProductWarehouse) ProductWarehouseDeta ProductWarehouseListDTO: ToProductWarehouseListDTO(e), } } + +func ToKandangBaseDTO(e entity.Kandang) KandangBaseDTO { + return KandangBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToLocationBaseDTO(e entity.Location) LocationBaseDTO { + return LocationBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToAreaBaseDTO(e entity.Area) AreaBaseDTO { + return AreaBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 7a1ff00e..4fad5dc5 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -34,7 +34,14 @@ func NewProductWarehouseService(repo repository.ProductWarehouseRepository, vali } func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("Product").Preload("Warehouse").Preload("CreatedUser") + return db. + Preload("Product.Flags"). + Preload("Product"). + Preload("Warehouse"). + Preload("Warehouse.Location"). + Preload("Warehouse.Area"). + Preload("Warehouse.Kandang"). + Preload("CreatedUser") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { diff --git a/internal/modules/inventory/route.go b/internal/modules/inventory/route.go index f37e8cad..fcb7881a 100644 --- a/internal/modules/inventory/route.go +++ b/internal/modules/inventory/route.go @@ -9,6 +9,7 @@ import ( productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments" + transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers" // MODULE IMPORTS ) @@ -19,6 +20,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida productWarehouses.ProductWarehouseModule{}, adjustments.AdjustmentModule{}, + transfers.TransferModule{}, // MODULE REGISTRY } diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go new file mode 100644 index 00000000..b53d6e9a --- /dev/null +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -0,0 +1,103 @@ +package controller + +import ( + "encoding/json" + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type TransferController struct { + TransferService service.TransferService +} + +func NewTransferController(transferService service.TransferService) *TransferController { + return &TransferController{ + TransferService: transferService, + } +} + +func (u *TransferController) 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.TransferService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.TransferListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all transfers successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToTransferListDTOs(result), + }) +} + +func (u *TransferController) 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.TransferService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get transfer successfully", + Data: dto.ToTransferListDTO(*result), + }) +} + +func (u *TransferController) CreateOne(c *fiber.Ctx) error { + data := c.FormValue("data") + + var req validation.TransferRequest + if err := json.Unmarshal([]byte(data), &req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + // ambil file + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + _ = form.File["documents"] + // todo: tunggu ada aws baru proses + + result, err := u.TransferService.CreateOne(c, &req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create transfer successfully", + Data: dto.ToTransferListDTO(*result), + }) +} diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go new file mode 100644 index 00000000..cb85af94 --- /dev/null +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -0,0 +1,245 @@ +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 TransferBaseDTO struct { + Id uint64 `json:"id"` + TransferReason string `json:"transfer_reason"` + TransferDate string `json:"transfer_date"` + SourceWarehouse *WarehouseDetailDTO `json:"source_warehouse,omitempty"` + DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` +} + +// Only id and name for warehouse simple view +type WarehouseSimpleDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ProductSimpleDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type AreaDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type SupplierSimpleDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type WarehouseDetailDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Location *LocationDTO `json:"location"` + Area *AreaDTO `json:"area"` +} + +type TransferListDTO struct { + TransferBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Details []TransferDetailItemDTO `json:"details"` + Deliveries []TransferDeliveryDTO `json:"deliveries"` +} + +type TransferDetailDTO struct { + TransferListDTO + Details []TransferDetailItemDTO `json:"details"` + Deliveries []TransferDeliveryDTO `json:"deliveries"` +} + +// Detail produk +type TransferDetailItemDTO struct { + Id uint64 `json:"id"` + Proudct ProductSimpleDTO `json:"product"` + Quantity float64 `json:"quantity"` +} + +// Delivery ekspedisi +type TransferDeliveryDTO struct { + Id uint64 `json:"id"` + Supplier SupplierSimpleDTO `json:"supplier"` + VehiclePlate string `json:"vehicle_plate"` + DriverName string `json:"driver_name"` + DocumentNumber string `json:"document_number"` + DocumentPath string `json:"document_path"` + ShippingCostItem float64 `json:"shipping_cost_item"` + ShippingCostTotal float64 `json:"shipping_cost_total"` + Items []TransferDeliveryItemDTO `json:"items"` +} + +type TransferDeliveryItemDTO struct { + Id uint64 `json:"id"` + StockTransferDetailId uint64 `json:"stock_transfer_detail_id"` + Quantity float64 `json:"quantity"` +} + +// === Mapper Functions === + +func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { + + var sourceWarehouse *WarehouseDetailDTO + if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { + sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) + } + var destinationWarehouse *WarehouseDetailDTO + if e.ToWarehouse != nil && e.ToWarehouse.Id != 0 { + destinationWarehouse = toWarehouseDetailDTO(e.ToWarehouse) + } + return TransferBaseDTO{ + Id: e.Id, + TransferReason: e.Reason, + TransferDate: e.CreatedAt.Format("2006-01-02"), + SourceWarehouse: sourceWarehouse, + DestinationWarehouse: destinationWarehouse, + } +} + +func toAreaDTO(a *entity.Area) *AreaDTO { + if a == nil { + return nil + } + return &AreaDTO{ + Id: a.Id, + Name: a.Name, + } +} + +func toLocationDTO(l *entity.Location) *LocationDTO { + if l == nil { + return nil + } + return &LocationDTO{ + Id: l.Id, + Name: l.Name, + } +} + +func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { + if w == nil { + return nil + } + return &WarehouseDetailDTO{ + Id: w.Id, + Name: w.Name, + Location: toLocationDTO(w.Location), + Area: toAreaDTO(&w.Area), // Ambil area langsung dari warehouse (area_id) + } +} + +func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser != nil { + mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) + createdUser = &mapped + } + // Map details + var details []TransferDetailItemDTO + for _, d := range e.Details { + details = append(details, TransferDetailItemDTO{ + Id: d.Id, + Proudct: ProductSimpleDTO{ + Id: d.Product.Id, + Name: d.Product.Name, + }, + Quantity: d.Quantity, + }) + } + // Map deliveries + var deliveries []TransferDeliveryDTO + for _, del := range e.Deliveries { + // Map delivery items + var items []TransferDeliveryItemDTO + for _, item := range del.Items { + items = append(items, TransferDeliveryItemDTO{ + Id: item.Id, + StockTransferDetailId: item.StockTransferDetailId, + Quantity: item.Quantity, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ + Id: del.Id, + Supplier: SupplierSimpleDTO{ + Id: del.Supplier.Id, + Name: del.Supplier.Name, + }, + VehiclePlate: del.VehiclePlate, + DriverName: del.DriverName, + DocumentNumber: del.DocumentNumber, + DocumentPath: del.DocumentPath, + ShippingCostItem: del.ShippingCostItem, + ShippingCostTotal: del.ShippingCostTotal, + Items: items, + }) + } + return TransferListDTO{ + TransferBaseDTO: ToTransferBaseDTO(e), + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Details: details, + Deliveries: deliveries, + } +} + +func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO { + result := make([]TransferListDTO, len(e)) + for i, r := range e { + result[i] = ToTransferListDTO(r) + } + return result +} + +func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { + // Map details + var details []TransferDetailItemDTO + for _, d := range e.Details { + details = append(details, TransferDetailItemDTO{ + Id: d.Id, + Proudct: ProductSimpleDTO{ + Id: d.Product.Id, + Name: d.Product.Name, + }, + Quantity: d.Quantity, + }) + } + // Map deliveries + var deliveries []TransferDeliveryDTO + for _, del := range e.Deliveries { + deliveries = append(deliveries, TransferDeliveryDTO{ + Id: del.Id, + Supplier: SupplierSimpleDTO{ + Id: del.Supplier.Id, + Name: del.Supplier.Name, + }, + VehiclePlate: del.VehiclePlate, + DriverName: del.DriverName, + DocumentNumber: del.DocumentNumber, + DocumentPath: del.DocumentPath, + ShippingCostItem: del.ShippingCostItem, + ShippingCostTotal: del.ShippingCostTotal, + }) + } + return TransferDetailDTO{ + TransferListDTO: ToTransferListDTO(e), + Details: details, + Deliveries: deliveries, + } +} diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go new file mode 100644 index 00000000..734f0f03 --- /dev/null +++ b/internal/modules/inventory/transfers/module.go @@ -0,0 +1,33 @@ +package transfers + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" + sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type TransferModule struct{} + +func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + stockTransferRepo := rStockTransfer.NewStockTransferRepository(db) + stockTransferDetailRepo := rStockTransfer.NewStockTransferDetailRepository(db) + stockTransferDeliveryRepo := rStockTransfer.NewStockTransferDeliveryRepository(db) + StockTransferDeliveryItemRepo := rStockTransfer.NewStockTransferDeliveryItemRepository(db) + stockLogsRepo := rStockLogs.NewStockLogRepository(db) + supplierRepo := rSupplier.NewSupplierRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + userRepo := rUser.NewUserRepository(db) + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo) + userService := sUser.NewUserService(userRepo, validate) + + TransferRoutes(router, userService, transferService) +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go new file mode 100644 index 00000000..e79d6310 --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go @@ -0,0 +1,34 @@ +package repositories + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferRepository interface { + repository.BaseRepository[entity.StockTransfer] + // get sequence for movement number + GetNextMovementNumber(ctx context.Context) (int64, error) +} + +type StockTransferRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransfer] +} + +func NewStockTransferRepository(db *gorm.DB) StockTransferRepository { + return &StockTransferRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransfer](db), + } +} + +func (r *StockTransferRepositoryImpl) GetNextMovementNumber(ctx context.Context) (int64, error) { + var seq int64 + err := r.DB().WithContext(ctx).Raw("SELECT nextval('stock_transfer_seq')").Scan(&seq).Error + if err != nil { + return 0, err + } + return seq, nil +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go new file mode 100644 index 00000000..ae0bfcf5 --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go @@ -0,0 +1,22 @@ +package repositories + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferDeliveryRepository interface { + repository.BaseRepository[entity.StockTransferDelivery] + // Tambahkan custom method jika perlu +} + +type StockTransferDeliveryRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransferDelivery] +} + +func NewStockTransferDeliveryRepository(db *gorm.DB) StockTransferDeliveryRepository { + return &StockTransferDeliveryRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDelivery](db), + } +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go new file mode 100644 index 00000000..86ba0e9b --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go @@ -0,0 +1,22 @@ +package repositories + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferDeliveryItemRepository interface { + repository.BaseRepository[entity.StockTransferDeliveryItem] + // Tambahkan custom method jika perlu +} + +type StockTransferDeliveryItemRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransferDeliveryItem] +} + +func NewStockTransferDeliveryItemRepository(db *gorm.DB) StockTransferDeliveryItemRepository { + return &StockTransferDeliveryItemRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDeliveryItem](db), + } +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go new file mode 100644 index 00000000..fa9afd57 --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go @@ -0,0 +1,29 @@ +// Find all details by StockTransferId + +package repositories + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferDetailRepository interface { + repository.BaseRepository[entity.StockTransferDetail] + FindByTransferId(ctx context.Context, transferId uint64, out *[]entity.StockTransferDetail) error +} + +type StockTransferDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransferDetail] +} + +func NewStockTransferDetailRepository(db *gorm.DB) StockTransferDetailRepository { + return &StockTransferDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDetail](db), + } +} +func (r *StockTransferDetailRepositoryImpl) FindByTransferId(ctx context.Context, transferId uint64, out *[]entity.StockTransferDetail) error { + return r.DB().WithContext(ctx).Where("stock_transfer_id = ?", transferId).Find(out).Error +} diff --git a/internal/modules/inventory/transfers/route.go b/internal/modules/inventory/transfers/route.go new file mode 100644 index 00000000..544a0674 --- /dev/null +++ b/internal/modules/inventory/transfers/route.go @@ -0,0 +1,27 @@ +package transfers + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/controllers" + transfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferService) { + ctrl := controller.NewTransferController(s) + + route := v1.Group("/transfers") + + // 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) + +} diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go new file mode 100644 index 00000000..90642f6c --- /dev/null +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -0,0 +1,315 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type TransferService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) + CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) +} + +type transferService struct { + Log *logrus.Logger + Validate *validator.Validate + StockTransferRepo rStockTransfer.StockTransferRepository + StockTransferDetailRepo rStockTransfer.StockTransferDetailRepository + StockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository + StockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository + StockLogsRepository rStockLogs.StockLogRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + SupplierRepo rSupplier.SupplierRepository +} + +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository) TransferService { + return &transferService{ + Log: utils.Log, + Validate: validate, + StockTransferRepo: stockTransferRepo, + StockTransferDetailRepo: stockTransferDetailRepo, + StockTransferDeliveryRepo: stockTransferDeliveryRepo, + StockTransferDeliveryItemRepo: stockTransferDeliveryItemRepo, + StockLogsRepository: stockLogsRepo, + ProductWarehouseRepo: productWarehouseRepo, + SupplierRepo: supplierRepo, + } +} +func (s transferService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("FromWarehouse"). + Preload("FromWarehouse.Location"). + Preload("FromWarehouse.Area"). + Preload("ToWarehouse"). + Preload("ToWarehouse.Location"). + Preload("ToWarehouse.Area"). + Preload("Details"). + Preload("Details.Product"). + Preload("Deliveries.Items"). + Preload("Deliveries.Supplier") +} + +func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + db = db.Where("movement_number LIKE ?", "%"+strings.TrimSpace(params.Search)+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + return nil, 0, err + } + + s.Log.Infof("Retrieved %d transfers", len(transfers)) + + return transfers, total, nil + +} + +func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { + var transfer entity.StockTransfer + + // gunakan repo secara langsung + transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return s.withRelations(db) + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + s.Log.Errorf("Failed to get transfer by ID: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") + } + + s.Log.Infof("Retrieved transfer: %+v", transfer) + + return transferPtr, nil +} + +func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { + + // Validasi stok di gudang asal harus exist dan mencukupi + for _, product := range req.Products { + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), + ) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek stok produk di gudang asal") + } + if sourcePW.Quantity < product.ProductQty { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) + } + } + + // validasi total qty harus lebih besar dari atau sama dengan total qty di delivery compare berdasarkan productid + deliveryQtyMap := make(map[uint]float64) + for _, delivery := range req.Deliveries { + for _, prod := range delivery.Products { + deliveryQtyMap[prod.ProductID] += prod.ProductQty + } + } + + // Cek: qty delivery tidak boleh melebihi qty di root + for _, product := range req.Products { + if deliveryQtyMap[product.ProductID] > product.ProductQty { + return nil, fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Total qty delivery untuk produk %d (%v) melebihi qty transfer (%v)", product.ProductID, deliveryQtyMap[product.ProductID], product.ProductQty)) + } + } + + // cek suplier id caegory BOP cek by id + for _, delivery := range req.Deliveries { + supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek data supplier") + } + if supplier.Category != "BOP" { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID)) + } + } + + // Generate movement number + // Format: PND-MBU-00001 + seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) + if err != nil { + s.Log.Errorf("Failed to get next movement number: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") + } + movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum) + transferDate, _ := utils.ParseDateString(req.TransferDate) + + entityTransfer := &entity.StockTransfer{ + FromWarehouseId: uint64(req.SourceWarehouseID), + ToWarehouseId: uint64(req.DestinationWarehouseID), + Reason: req.TransferReason, + TransferDate: transferDate, + MovementNumber: movementNumber, + CreatedBy: 1, //todo: get from token + } + + // Save the transfer entity to the database + err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + + // Insert header + if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer: %+v", err) + return err + } + s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) + + // insert ke details + var details []*entity.StockTransferDetail + for _, product := range req.Products { + details = append(details, &entity.StockTransferDetail{ + StockTransferId: entityTransfer.Id, + ProductId: uint64(product.ProductID), + Quantity: product.ProductQty, + }) + } + if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer details: %+v", err) + return err + } + s.Log.Infof("Stock transfer details created for transfer ID: %+v", entityTransfer.Id) + + // Tambahkan proses insert delivery + var deliveries []*entity.StockTransferDelivery + for _, delivery := range req.Deliveries { + deliveries = append(deliveries, &entity.StockTransferDelivery{ + StockTransferId: entityTransfer.Id, + SupplierId: uint64(delivery.SupplierID), + VehiclePlate: delivery.VehiclePlate, + DriverName: delivery.DriverName, + DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", // todo: tunggu ada aws baru proses + ShippingCostItem: delivery.DeliveryCostPerItem, + ShippingCostTotal: delivery.DeliveryCost, + }) + } + if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) + return err + } + // tambahkan insert ke delivery items sebagai pivot + detailMap := make(map[uint64]uint64) + for _, d := range details { + detailMap[d.ProductId] = d.Id + } + + var deliveryItems []*entity.StockTransferDeliveryItem + + for i, delivery := range deliveries { + item := req.Deliveries[i] + for _, prod := range item.Products { + detailID, ok := detailMap[uint64(prod.ProductID)] + if !ok { + return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) + } + deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ + StockTransferDeliveryId: delivery.Id, + StockTransferDetailId: detailID, + Quantity: prod.ProductQty, + }) + } + } + if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer delivery items: %+v", err) + return err + } + s.Log.Infof("Stock transfer delivery items created for transfer ID: %+v", entityTransfer.Id) + + // Proses pengurangan stok di gudang asal dan penambahan stok di gudang tujuan + for _, product := range req.Products { + // Kurangi stok di gudang asal + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) + if err != nil { + s.Log.Errorf("Failed to get source product warehouse: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") + } + if sourcePW.Quantity < product.ProductQty { + s.Log.Errorf("Insufficient stock in source warehouse for product ID: %+v", product.ProductID) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID)) + } + sourcePW.Quantity -= product.ProductQty + if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil { + s.Log.Errorf("Failed to update source product warehouse: %+v", err) + return err + } + s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) + + // Tambah stok di gudang tujuan + destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), + ) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to get destination product warehouse: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") + } + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + // Jika belum ada record untuk produk di gudang tujuan, buat baru + destPW = &entity.ProductWarehouse{ + ProductId: uint(product.ProductID), + WarehouseId: uint(req.DestinationWarehouseID), + Quantity: 0, + CreatedBy: 1, // TODO: should Get from auth middleware + } + if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { + s.Log.Errorf("Failed to create destination product warehouse: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") + } + s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) + } + // Update stok di gudang tujuan + destPW.Quantity += product.ProductQty + if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { + s.Log.Errorf("Failed to update destination product warehouse: %+v", err) + return err + } + s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) + + } + + return nil + }) + + if err != nil { + s.Log.Errorf("Transaction failed in CreateOne: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") + } + + // Ambil data lengkap hasil create dengan GetOne (agar preload relasi sama dengan GetOne) + result, err := s.GetOne(c, uint(entityTransfer.Id)) + if err != nil { + return nil, err + } + return result, nil +} diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go new file mode 100644 index 00000000..c64077ff --- /dev/null +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -0,0 +1,40 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +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"` +} + +type TransferProduct struct { + ProductID uint `json:"product_id" validate:"required"` + ProductQty float64 `json:"product_qty" validate:"required,gt=0"` +} + +type TransferDeliveryProduct struct { + ProductID uint `json:"product_id" validate:"required"` + ProductQty float64 `json:"product_qty" validate:"required,gt=0"` +} + +type TransferDelivery struct { + DeliveryCost float64 `json:"delivery_cost" validate:"required"` + DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"` + DocumentIndex int `json:"document_index" validate:"min=0"` + DriverName string `json:"driver_name" validate:"required"` + VehiclePlate string `json:"vehicle_plate" validate:"required"` + SupplierID uint `json:"supplier_id" validate:"required"` + Products []TransferDeliveryProduct `json:"products" validate:"required,dive"` +} + +type TransferRequest struct { + TransferReason string `json:"transfer_reason" validate:"required"` + TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"` + SourceWarehouseID uint `json:"source_warehouse_id" validate:"required"` + DestinationWarehouseID uint `json:"destination_warehouse_id" validate:"required"` + Products []TransferProduct `json:"products" validate:"required,dive"` + Deliveries []TransferDelivery `json:"deliveries" validate:"required,dive"` +} 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..006fe541 --- /dev/null +++ b/internal/modules/master/flocks/repositories/flock.repository.go @@ -0,0 +1,30 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type FlockRepository interface { + repository.BaseRepository[entity.Flock] + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) +} + +type FlockRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Flock] + db *gorm.DB +} + +func NewFlockRepository(db *gorm.DB) FlockRepository { + return &FlockRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Flock](db), + db: db, + } +} + +func (r *FlockRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Flock](ctx, r.db, name, excludeID) +} 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..ad086920 --- /dev/null +++ b/internal/modules/master/flocks/services/flock.service.go @@ -0,0 +1,159 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + 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 + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Name is required") + } + + exists, err := s.Repository.NameExists(c.Context(), name, nil) + if err != nil { + s.Log.Errorf("Failed to check flock name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check flock name") + } + if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Flock with name %s already exists", name)) + } + + createBody := &entity.Flock{ + Name: 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 { + name := strings.TrimSpace(*req.Name) + if name == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Name cannot be empty") + } + + exists, err := s.Repository.NameExists(c.Context(), name, &id) + if err != nil { + s.Log.Errorf("Failed to check flock name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check flock name") + } + if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Flock with name %s already exists", name)) + } + updateBody["name"] = 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..22546339 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,10 @@ 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) + GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error) + HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) + UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error } type KandangRepositoryImpl struct { @@ -38,3 +43,49 @@ 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 +} + +func (r *KandangRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error) { + kandang := new(entity.Kandang) + err := r.db.WithContext(ctx). + Where("project_flock_id = ?", projectFlockID). + First(kandang).Error + if err != nil { + return nil, err + } + return kandang, nil +} + +func (r *KandangRepositoryImpl) UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error { + return r.db.WithContext(ctx). + Model(&entity.Kandang{}). + Where("project_flock_id = ?", projectFlockID). + Update("status", string(status)).Error +} 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/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index ea4e43bf..46fb2983 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -11,6 +11,7 @@ import ( type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + } type SupplierRepositoryImpl struct { diff --git a/internal/modules/master/warehouses/repositories/warehouse.repository.go b/internal/modules/master/warehouses/repositories/warehouse.repository.go index 5c791e01..956c30ef 100644 --- a/internal/modules/master/warehouses/repositories/warehouse.repository.go +++ b/internal/modules/master/warehouses/repositories/warehouse.repository.go @@ -15,6 +15,7 @@ type WarehouseRepository interface { KandangExists(ctx context.Context, kandangId uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) IdExists(ctx context.Context, id uint) (bool, error) + GetByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) } type WarehouseRepositoryImpl struct { @@ -47,3 +48,15 @@ func (r *WarehouseRepositoryImpl) NameExists(ctx context.Context, name string, e func (r *WarehouseRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { return repository.Exists[entity.Warehouse](ctx, r.db, id) } + +func (r *WarehouseRepositoryImpl) GetByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) { + var warehouse entity.Warehouse + err := r.db.WithContext(ctx). + Where("kandang_id = ?", kandangId). + Where("deleted_at IS NULL"). + First(&warehouse).Error + if err != nil { + return nil, err + } + return &warehouse, nil +} diff --git a/internal/modules/production/chickins/controllers/chickin.controller.go b/internal/modules/production/chickins/controllers/chickin.controller.go new file mode 100644 index 00000000..fadcbc3e --- /dev/null +++ b/internal/modules/production/chickins/controllers/chickin.controller.go @@ -0,0 +1,161 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ChickinController struct { + ChickinService service.ChickinService +} + +func NewChickinController(chickinService service.ChickinService) *ChickinController { + return &ChickinController{ + ChickinService: chickinService, + } +} + +func (u *ChickinController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), + } + + result, totalResults, err := u.ChickinService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all chickins successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToChickinListDTOs(result), + }) +} + +func (u *ChickinController) 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.ChickinService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get chickin successfully", + Data: dto.ToChickinListDTO(*result), + }) +} + +func (u *ChickinController) 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.ChickinService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create chickin successfully", + Data: dto.ToChickinListDTO(*result), + }) +} + +func (u *ChickinController) 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.ChickinService.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 chickin successfully", + Data: dto.ToChickinListDTO(*result), + }) +} + +func (u *ChickinController) 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.ChickinService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete chickin successfully", + }) +} + +func (u *ChickinController) Approve(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.ChickinService.Approve(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Approve chickin successfully", + Data: nil, + }) +} diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go new file mode 100644 index 00000000..193257b6 --- /dev/null +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -0,0 +1,205 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + areaBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" + fcrBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" + flockBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" + kandangBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + locationBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + userBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs (ordered) === + +type ChickinBaseDTO struct { + Id uint `json:"id"` + ProjectFlockKandang *ProjectFlockKandangDTO `json:"project_flock_kandang"` + ChickInDate time.Time `json:"chick_in_date"` + Quantity float64 `json:"quantity"` + Note string `json:"note"` +} + +type ProjectFlockDTO struct { + Id uint `json:"id"` + Period int `json:"period"` + Category string `json:"category"` + Flock *flockBaseDTO.FlockBaseDTO `json:"flock"` + Area *areaBaseDTO.AreaBaseDTO `json:"area"` + Fcr *fcrBaseDTO.FcrBaseDTO `json:"fcr"` + Location *locationBaseDTO.LocationBaseDTO `json:"location"` +} + +type ProjectFlockKandangDTO struct { + Id uint `json:"id"` + ProjectFlock *ProjectFlockDTO `json:"project_flock"` + Kandang *kandangBaseDTO.KandangBaseDTO `json:"kandang"` +} + +// gunakan base DTO dari package users + +type ChickinSimpleDTO struct { + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ChickInDate time.Time `json:"chick_in_date"` + Quantity float64 `json:"quantity"` + Note string `json:"note"` + CreatedBy uint `json:"created_by"` +} + +type ChickinListDTO struct { + ChickinBaseDTO + ProjectFlockKandang *ProjectFlockKandangDTO `json:"project_flock_kandang"` + CreatedUser *userBaseDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ChickinDetailDTO struct { + ChickinListDTO +} + +// === Mapper Functions (ordered) === + +func ToFlockDTO(e entity.Flock) flockBaseDTO.FlockBaseDTO { + return flockBaseDTO.ToFlockBaseDTO(e) +} + +func ToKandangDTO(e entity.Kandang) kandangBaseDTO.KandangBaseDTO { + return kandangBaseDTO.ToKandangBaseDTO(e) +} +func ToAreaDTO(e entity.Area) areaBaseDTO.AreaBaseDTO { + return areaBaseDTO.ToAreaBaseDTO(e) +} + +func ToFcrDTO(e entity.Fcr) fcrBaseDTO.FcrBaseDTO { + return fcrBaseDTO.ToFcrBaseDTO(e) +} + +func ToLocationDTO(e entity.Location) locationBaseDTO.LocationBaseDTO { + return locationBaseDTO.ToLocationBaseDTO(e) +} + +func ToUserBaseDTO(e entity.User) userBaseDTO.UserBaseDTO { + return userBaseDTO.ToUserBaseDTO(e) +} + +func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { + var flock *flockBaseDTO.FlockBaseDTO + if e.Flock.Id != 0 { + mapped := flockBaseDTO.ToFlockBaseDTO(e.Flock) + flock = &mapped + } + var area *areaBaseDTO.AreaBaseDTO + if e.Area.Id != 0 { + mapped := areaBaseDTO.ToAreaBaseDTO(e.Area) + area = &mapped + } + var fcr *fcrBaseDTO.FcrBaseDTO + if e.Fcr.Id != 0 { + mapped := fcrBaseDTO.ToFcrBaseDTO(e.Fcr) + fcr = &mapped + } + var location *locationBaseDTO.LocationBaseDTO + if e.Location.Id != 0 { + mapped := locationBaseDTO.ToLocationBaseDTO(e.Location) + location = &mapped + } + return ProjectFlockDTO{ + Id: e.Id, + Period: e.Period, + Category: e.Category, + Flock: flock, + Area: area, + Fcr: fcr, + Location: location, + } +} + +func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { + var pf *ProjectFlockDTO + if e.ProjectFlock.Id != 0 { + mapped := ToProjectFlockDTO(e.ProjectFlock) + pf = &mapped + } + var kandang *kandangBaseDTO.KandangBaseDTO + if e.Kandang.Id != 0 { + mapped := kandangBaseDTO.ToKandangBaseDTO(e.Kandang) + kandang = &mapped + } + return ProjectFlockKandangDTO{ + Id: e.Id, + ProjectFlock: pf, + Kandang: kandang, + } +} + +func ToChickinBaseDTO(e entity.ProjectChickin) ChickinBaseDTO { + var pfk *ProjectFlockKandangDTO + if e.ProjectFlockKandang.Id != 0 { + mapped := ToProjectFlockKandangDTO(e.ProjectFlockKandang) + pfk = &mapped + } + return ChickinBaseDTO{ + Id: e.Id, + ProjectFlockKandang: pfk, + ChickInDate: e.ChickInDate, + Quantity: e.Quantity, + Note: e.Note, + } +} + +func ToChickinSimpleDTO(e entity.ProjectChickin) ChickinSimpleDTO { + return ChickinSimpleDTO{ + Id: e.Id, + ProjectFlockKandangId: e.ProjectFlockKandangId, + ChickInDate: e.ChickInDate, + Quantity: e.Quantity, + Note: e.Note, + CreatedBy: e.CreatedBy, + } +} + +func ToChickinListDTO(e entity.ProjectChickin) ChickinListDTO { + var createdUser *userBaseDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userBaseDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + var pfk *ProjectFlockKandangDTO + if e.ProjectFlockKandang.Id != 0 { + mapped := ToProjectFlockKandangDTO(e.ProjectFlockKandang) + pfk = &mapped + } + return ChickinListDTO{ + ChickinBaseDTO: ToChickinBaseDTO(e), + ProjectFlockKandang: pfk, + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToChickinListDTOs(e []entity.ProjectChickin) []ChickinListDTO { + result := make([]ChickinListDTO, len(e)) + for i, r := range e { + result[i] = ToChickinListDTO(r) + } + return result +} + +func ToChickinSimpleDTOs(e []entity.ProjectChickin) []ChickinSimpleDTO { + result := make([]ChickinSimpleDTO, len(e)) + for i, r := range e { + result[i] = ToChickinSimpleDTO(r) + } + return result +} + +func ToChickinDetailDTO(e entity.ProjectChickin) ChickinDetailDTO { + return ChickinDetailDTO{ + ChickinListDTO: ToChickinListDTO(e), + } +} diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go new file mode 100644 index 00000000..116e2fbb --- /dev/null +++ b/internal/modules/production/chickins/module.go @@ -0,0 +1,39 @@ +package chickins + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" + rAuditLog "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ChickinModule struct{} + +func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + chickinRepo := rChickin.NewChickinRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) + auditlogrepo := rAuditLog.NewAuditLogRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + projectflockkandangrepo := rProjectFlock.NewProjectFlockKandangRepository(db) + projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) + projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + + userRepo := rUser.NewUserRepository(db) + + chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, auditlogrepo, projectflockkandangrepo, projectflockpopulationrepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ChickinRoutes(router, userService, chickinService) +} diff --git a/internal/modules/production/chickins/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go new file mode 100644 index 00000000..64e2e4b4 --- /dev/null +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -0,0 +1,36 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProjectChickinRepository interface { + repository.BaseRepository[entity.ProjectChickin] + GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.ProjectChickin, error) +} + +type ChickinRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectChickin] +} + +func NewChickinRepository(db *gorm.DB) ProjectChickinRepository { + return &ChickinRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectChickin](db), + } +} + +func (r *ChickinRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.ProjectChickin, error) { + var chickin entity.ProjectChickin + err := r.DB().WithContext(ctx). + Where("project_floc_id = ?", projectFlockID). + Where("deleted_at IS NULL"). + First(&chickin).Error + if err != nil { + return nil, err + } + return &chickin, nil +} diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go new file mode 100644 index 00000000..5fa5237a --- /dev/null +++ b/internal/modules/production/chickins/route.go @@ -0,0 +1,29 @@ +package chickins + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/controllers" + chickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService) { + ctrl := controller.NewChickinController(s) + + route := v1.Group("/chickins") + + // 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.Post("/:id/approve", ctrl.Approve) +} diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go new file mode 100644 index 00000000..43105374 --- /dev/null +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -0,0 +1,370 @@ +package service + +import ( + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + KandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + AuditLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ChickinService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectChickin, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + Approve(ctx *fiber.Ctx, id uint) error +} + +type chickinService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectChickinRepository + KandangRepo KandangRepo.KandangRepository + WarehouseRepo rWarehouse.WarehouseRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockRepo rProjectFlock.ProjectflockRepository + AuditLogRepo AuditLogRepo.AuditLogRepository + ProjectflockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository + ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository +} + +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, auditLogRepo AuditLogRepo.AuditLogRepository, projectflockkandangRepo rProjectFlockKandang.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, validate *validator.Validate) ChickinService { + return &chickinService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + KandangRepo: kandangRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockRepo: projectFlockRepo, + AuditLogRepo: auditLogRepo, + ProjectflockKandangRepo: projectflockkandangRepo, + ProjectflockPopulationRepo: projectflockpopulationRepo, + } +} + +func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProjectFlockKandang.Kandang"). + Preload("ProjectFlockKandang.Kandang.Location"). + Preload("ProjectFlockKandang.Kandang.Location.Area"). + Preload("ProjectFlockKandang.Kandang.Pic"). + Preload("ProjectFlockKandang.ProjectFlock"). + Preload("ProjectFlockKandang.ProjectFlock.Flock"). + Preload("ProjectFlockKandang.ProjectFlock.Area"). + Preload("ProjectFlockKandang.ProjectFlock.Fcr"). + Preload("ProjectFlockKandang.ProjectFlock.Location"). + Preload("ProjectFlockKandang.ProjectFlock.Location.Area") + +} + +func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + chickins, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.ProjectFlockKandangId != 0 { + return db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + if err != nil { + s.Log.Errorf("Failed to get chickins: %+v", err) + return nil, 0, err + } + return chickins, total, nil +} + +func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, error) { + + chickin, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + if err != nil { + s.Log.Errorf("Failed get chickin by id: %+v", err) + return nil, err + } + return chickin, nil +} + +func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProjectChickin, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + projectflockkandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), 1) + if err != nil { + s.Log.Errorf("Failed to get projectflock kandang: %+v", err) + return nil, err + } + + warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectflockkandang.KandangId) + if err != nil { + s.Log.Errorf("Failed to get warehouse: %+v", err) + return nil, err + } + + projectFlock, err := s.ProjectFlockRepo.GetByID( + c.Context(), + projectflockkandang.ProjectFlockId, + func(db *gorm.DB) *gorm.DB { + return db + }, + ) + if err != nil { + s.Log.Errorf("Failed to get project flock: %+v", err) + return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") + } + var productWarehouses []entity.ProductWarehouse + err = s.ProductWarehouseRepo.DB(). + WithContext(c.Context()). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", projectFlock.Category, warehouse.Id). + Order("created_at DESC"). + Find(&productWarehouses).Error + if err != nil { + s.Log.Errorf("Failed to get product warehouses: %+v", err) + return nil, err + } + if len(productWarehouses) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") + } + + // Jumlahkan semua quantity DOC + totalQuantity := 0.0 + for _, pw := range productWarehouses { + totalQuantity += pw.Quantity + } + + if totalQuantity < 1 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient quantity in Product Warehouses") + } + + // Buat satu chickin dengan total quantity + chickinDate, err := utils.ParseDateString(req.ChickInDate) + if err != nil { + s.Log.Errorf("Failed to parse chickin date: %+v", err) + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid ChickInDate format") + } + newChickin := &entity.ProjectChickin{ + ProjectFlockKandangId: projectflockkandang.ProjectFlockId, + ChickInDate: chickinDate, + Quantity: totalQuantity, + Note: "", + CreatedBy: 1, //todo: ganti dengan user login + } + err = s.Repository.CreateOne(c.Context(), newChickin, nil) + if err != nil { + s.Log.Errorf("Failed to create chickin: %+v", err) + return nil, err + } + + // Update semua product warehouse: set quantity jadi 0 + for _, pw := range productWarehouses { + err = s.ProductWarehouseRepo.PatchOne(c.Context(), pw.Id, map[string]any{ + "quantity": 0, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) + return nil, err + } + } + + existingPopulation, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to get project flock population: %+v", err) + return nil, err + } + if existingPopulation != nil { + + err = s.ProjectflockPopulationRepo.PatchOne(c.Context(), existingPopulation.Id, map[string]any{ + "reserved_quantity": newChickin.Quantity + existingPopulation.ReservedQuantity, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update project flock population: %+v", err) + return nil, err + } + } else { + newPopulation := &entity.ProjectFlockPopulation{ + ProjectFlockKandangId: req.ProjectFlockKandangId, + InitialQuantity: 0, + CurrentQuantity: 0, + ReservedQuantity: newChickin.Quantity, + CreatedBy: 1, // todo: ganti dengan user login + } + err = s.ProjectflockPopulationRepo.CreateOne(c.Context(), newPopulation, nil) + if err != nil { + s.Log.Errorf("Failed to create project flock population: %+v", err) + return nil, err + } + } + + return s.GetOne(c, newChickin.Id) +} + +func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.ChickInDate != "" { + updateBody["chick_in_date"] = req.ChickInDate + } + 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, "Chickin not found") + } + s.Log.Errorf("Failed to update chickin: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { + // todo: cek apakah chickin sudah di approve atau belum + + chickin, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + if err != nil { + s.Log.Errorf("Failed get chickin by id: %+v", err) + return err + } + + population, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), chickin.ProjectFlockKandangId) + if err != nil { + s.Log.Errorf("Failed to get project flock population: %+v", err) + return err + } + + err = s.ProjectflockPopulationRepo.PatchOne(c.Context(), population.Id, map[string]any{ + "reserved_quantity": population.ReservedQuantity - chickin.Quantity, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update project flock population: %+v", err) + return err + } + + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + s.Log.Errorf("Failed to delete chickin: %+v", err) + return err + } + + projectflockkandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), population.ProjectFlockKandangId) + if err != nil { + s.Log.Errorf("Failed to get projectflock kandang: %+v", err) + return err + } + warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectflockkandang.KandangId) + if err != nil { + s.Log.Errorf("Failed to get warehouse: %+v", err) + return err + } + + projectFlock, err := s.ProjectFlockRepo.GetByID( + c.Context(), + projectflockkandang.ProjectFlockId, + func(db *gorm.DB) *gorm.DB { + return db + }, + ) + + if err != nil { + s.Log.Errorf("Failed to get project flock: %+v", err) + return fiber.NewError(fiber.StatusNotFound, "Project Flock not found") + } + var productWarehouse entity.ProductWarehouse + err = s.ProductWarehouseRepo.DB().WithContext(c.Context()). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", projectFlock.Category, warehouse.Id). + Order("created_at DESC"). + First(&productWarehouse).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") + } + s.Log.Errorf("Failed to get product warehouse: %+v", err) + return err + } + + updatedQuantity := productWarehouse.Quantity + chickin.Quantity + err = s.ProductWarehouseRepo.PatchOne(c.Context(), productWarehouse.Id, map[string]any{ + "quantity": updatedQuantity, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) + return err + } + + return nil +} + +func (s *chickinService) Approve(c *fiber.Ctx, id uint) error { + + // todo: ini contoh akhir jika sudah approved + + chickin, err := s.Repository.GetByID( + c.Context(), + id, + nil, + ) + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + if err != nil { + s.Log.Errorf("Failed get chickin by id: %+v", err) + return err + } + + population, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), chickin.ProjectFlockKandangId) + if err != nil { + s.Log.Errorf("Failed to get project flock population: %+v", err) + return err + } + + err = s.ProjectflockPopulationRepo.PatchOne(c.Context(), population.Id, map[string]any{ + "reserved_quantity": population.ReservedQuantity - chickin.Quantity, + "initial_quantity": population.InitialQuantity + chickin.Quantity, + "current_quantity": population.CurrentQuantity + chickin.Quantity, + }, nil) + if err != nil { + s.Log.Errorf("Failed to update project flock population: %+v", err) + return err + } + + return nil +} diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go new file mode 100644 index 00000000..c122c100 --- /dev/null +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -0,0 +1,16 @@ +package validation + +type Create struct { + ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` + ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` +} + +type Update struct { + ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` +} 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..31d0b9f0 --- /dev/null +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -0,0 +1,242 @@ +package controller + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "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 { + parseUintList := func(raw string) ([]uint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + var ids []uint + if strings.HasPrefix(raw, "[") { + if err := json.Unmarshal([]byte(raw), &ids); err == nil { + return ids, nil + } + } + + parts := strings.Split(raw, ",") + for _, part := range parts { + part = strings.Trim(part, " \"[]") + if part == "" { + continue + } + v, err := strconv.Atoi(part) + if err != nil || v <= 0 { + return nil, fmt.Errorf("invalid kandang id: %s", part) + } + ids = append(ids, uint(v)) + } + return ids, nil + } + + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + SortBy: c.Query("sort_by", ""), + SortOrder: c.Query("sort_order", ""), + } + + if area := c.QueryInt("area_id", 0); area > 0 { + query.AreaId = uint(area) + } + if location := c.QueryInt("location_id", 0); location > 0 { + query.LocationId = uint(location) + } + if period := c.QueryInt("period", 0); period > 0 { + query.Period = period + } + + if kandangRaw := c.Query("kandang_id", c.Query("kandang_ids", "")); kandangRaw != "" { + ids, err := parseUintList(kandangRaw) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + query.KandangIds = ids + } + + 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) Approval(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + req := new(validation.Approve) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProjectflockService.Approval(c, uint(id), req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Submit projectflock approval successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} + +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..cb35eb0f --- /dev/null +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -0,0 +1,176 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" + fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" + flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" +) + +type ProjectFlockBaseDTO struct { + Id uint `json:"id"` + Period int `json:"period"` + Category string `json:"category"` + Flock *flockDTO.FlockBaseDTO `json:"flock"` + Area *areaDTO.AreaBaseDTO `json:"area"` + Fcr *fcrDTO.FcrBaseDTO `json:"fcr"` + Location *locationDTO.LocationBaseDTO `json:"location"` +} + +type ProjectFlockListDTO struct { + ProjectFlockBaseDTO + Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` + Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrBaseDTO `json:"fcr,omitempty"` + Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` + Kandangs []kandangDTO.KandangBaseDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalBaseDTO `json:"approval"` +} + +type ProjectFlockDetailDTO struct { + ProjectFlockListDTO +} + +type FlockPeriodDTO struct { + Flock flockDTO.FlockBaseDTO `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 kandangSummaries []kandangDTO.KandangBaseDTO + if len(e.Kandangs) > 0 { + kandangSummaries = make([]kandangDTO.KandangBaseDTO, len(e.Kandangs)) + for i, kandang := range e.Kandangs { + kandangSummaries[i] = kandangDTO.ToKandangBaseDTO(kandang) + } + } + + latestApproval := defaultProjectFlockLatestApproval(e) + if e.LatestApproval != nil { + snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = snapshot + } + + return ProjectFlockListDTO{ + ProjectFlockBaseDTO: createProjectFlockBaseDTO(e), + Kandangs: kandangSummaries, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + Approval: latestApproval, + } +} + +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 defaultProjectFlockLatestApproval(e entity.ProjectFlock) approvalDTO.ApprovalBaseDTO { + result := approvalDTO.ApprovalBaseDTO{} + + step := utils.ProjectFlockStepPengajuan + if step > 0 { + result.StepNumber = uint16(step) + if label, ok := approvalutils.ApprovalStepName(utils.ApprovalWorkflowProjectFlock, step); ok { + result.StepName = label + } else if label, ok := utils.ProjectFlockApprovalSteps[step]; ok { + result.StepName = label + } + } + if result.StepName == "" { + result.StepName = "Pengajuan" + } + + if !e.CreatedAt.IsZero() { + result.ActionAt = e.CreatedAt + } + + if e.CreatedUser.Id != 0 { + result.ActionBy = userDTO.ToUserBaseDTO(e.CreatedUser) + } else if e.CreatedBy != 0 { + result.ActionBy = userDTO.UserBaseDTO{ + Id: e.CreatedBy, + IdUser: int64(e.CreatedBy), + } + } + + return result +} + +func createProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { + var flock *flockDTO.FlockBaseDTO + if e.Flock.Id != 0 { + mapped := flockDTO.ToFlockBaseDTO(e.Flock) + flock = &mapped + } + + var area *areaDTO.AreaBaseDTO + if e.Area.Id != 0 { + mapped := areaDTO.ToAreaBaseDTO(e.Area) + area = &mapped + } + + var fcr *fcrDTO.FcrBaseDTO + if e.Fcr.Id != 0 { + mapped := fcrDTO.ToFcrBaseDTO(e.Fcr) + fcr = &mapped + } + + var location *locationDTO.LocationBaseDTO + if e.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(e.Location) + location = &mapped + } + + return ProjectFlockBaseDTO{ + Id: e.Id, + Period: e.Period, + Category: e.Category, + Flock: flock, + Area: area, + Fcr: fcr, + Location: location, + } +} + +func ToFlockSummaryDTO(e entity.Flock) flockDTO.FlockBaseDTO { + return flockDTO.FlockBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToFlockPeriodSummaryDTO(flock entity.Flock, next int) FlockPeriodDTO { + return FlockPeriodDTO{ + 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..994eb4a4 --- /dev/null +++ b/internal/modules/production/project_flocks/module.go @@ -0,0 +1,42 @@ +package project_flocks + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "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" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + + 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) + projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlock, utils.ProjectFlockApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) + } + + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + ProjectflockRoutes(router, userService, projectflockService) +} diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go new file mode 100644 index 00000000..cb4b0d5f --- /dev/null +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -0,0 +1,35 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProjectFlockPopulationRepository interface { + repository.BaseRepository[entity.ProjectFlockPopulation] + GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*entity.ProjectFlockPopulation, error) +} + +type projectFlockPopulationRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectFlockPopulation] +} + +func NewProjectFlockPopulationRepository(db *gorm.DB) ProjectFlockPopulationRepository { + return &projectFlockPopulationRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockPopulation](db), + } +} + +func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*entity.ProjectFlockPopulation, error) { + var record entity.ProjectFlockPopulation + err := r.DB().WithContext(ctx). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} 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/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go new file mode 100644 index 00000000..a5ceaf7f --- /dev/null +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -0,0 +1,75 @@ +package repository + +import ( + "context" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProjectFlockKandangRepository interface { + GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) + CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error + DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error + GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) + WithTx(tx *gorm.DB) ProjectFlockKandangRepository + DB() *gorm.DB +} + +type projectFlockKandangRepositoryImpl struct { + db *gorm.DB +} + +func NewProjectFlockKandangRepository(db *gorm.DB) ProjectFlockKandangRepository { + return &projectFlockKandangRepositoryImpl{db: db} +} + +func (r *projectFlockKandangRepositoryImpl) CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error { + if len(records) == 0 { + return nil + } + return r.db.WithContext(ctx).Create(&records).Error +} + +func (r *projectFlockKandangRepositoryImpl) DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error { + if len(kandangIDs) == 0 { + return nil + } + return r.db.WithContext(ctx). + Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs). + Delete(&entity.ProjectFlockKandang{}).Error +} + +func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) { + var records []entity.ProjectFlockKandang + if err := r.db.WithContext(ctx). + Preload("ProjectFlock"). + Preload("ProjectFlock.Flock"). + Preload("Kandang"). + Order("project_flock_id ASC, created_at ASC"). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository { + return &projectFlockKandangRepositoryImpl{db: tx} +} + +func (r *projectFlockKandangRepositoryImpl) DB() *gorm.DB { + return r.db +} + +func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) { + record := new(entity.ProjectFlockKandang) + if err := r.db.WithContext(ctx). + Preload("ProjectFlock"). + Preload("ProjectFlock.Flock"). + Preload("Kandang"). + Preload("CreatedUser"). + First(record, id).Error; err != nil { + return nil, err + } + return record, 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..7282c020 --- /dev/null +++ b/internal/modules/production/project_flocks/route.go @@ -0,0 +1,30 @@ +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.Post("/:id/approvals", ctrl.Approval) + 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..49401dd4 --- /dev/null +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -0,0 +1,752 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + 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" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "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) + Approval(ctx *fiber.Ctx, id uint, req *validation.Approve) (*entity.ProjectFlock, error) +} + +type projectflockService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectflockRepository + FlockRepo flockRepository.FlockRepository + KandangRepo kandangRepository.KandangRepository + PivotRepo repository.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +type FlockPeriodSummary struct { + Flock entity.Flock + NextPeriod int +} + +func NewProjectflockService( + repo repository.ProjectflockRepository, + flockRepo flockRepository.FlockRepository, + kandangRepo kandangRepository.KandangRepository, + pivotRepo repository.ProjectFlockKandangRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) ProjectflockService { + return &projectflockService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + FlockRepo: flockRepo, + KandangRepo: kandangRepo, + PivotRepo: pivotRepo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowProjectFlock, + } +} + +func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Flock"). + Preload("Area"). + Preload("Fcr"). + Preload("Location"). + Preload("Kandangs.Location") +} + +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 + } + + if params.Page <= 0 { + params.Page = 1 + } + if params.Limit <= 0 { + params.Limit = 10 + } + + 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) + + if params.AreaId > 0 { + db = db.Where("project_flocks.area_id = ?", params.AreaId) + } + if params.LocationId > 0 { + db = db.Where("project_flocks.location_id = ?", params.LocationId) + } + if params.Period > 0 { + db = db.Where("project_flocks.period = ?", params.Period) + } + if len(params.KandangIds) > 0 { + db = db.Where("EXISTS (SELECT 1 FROM kandangs WHERE kandangs.project_flock_id = project_flocks.id AND kandangs.id IN ?)", params.KandangIds) + } + + if params.Search != "" { + normalizedSearch := strings.ToLower(strings.TrimSpace(params.Search)) + if normalizedSearch == "" { + for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { + db = db.Order(expr) + } + return db + } + likeQuery := "%" + normalizedSearch + "%" + db = db. + Joins("LEFT JOIN flocks ON flocks.id = project_flocks.flock_id"). + Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). + Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id"). + Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id"). + Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). + Where(` + LOWER(flocks.name) LIKE ? + OR LOWER(areas.name) LIKE ? + OR LOWER(project_flocks.category) LIKE ? + OR LOWER(fcrs.name) LIKE ? + OR LOWER(locations.name) LIKE ? + OR LOWER(locations.address) LIKE ? + OR LOWER(created_users.name) LIKE ? + OR LOWER(created_users.email) LIKE ? + OR LOWER(CAST(project_flocks.period AS TEXT)) LIKE ? + OR EXISTS ( + SELECT 1 FROM kandangs + WHERE kandangs.project_flock_id = project_flocks.id + AND LOWER(kandangs.name) LIKE ? + ) + `, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + ) + } + for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { + db = db.Order(expr) + } + return db + }) + + if err != nil { + s.Log.Errorf("Failed to get projectflocks: %+v", err) + return nil, 0, err + } + + if s.ApprovalSvc != nil && len(projectflocks) > 0 { + ids := make([]uint, len(projectflocks)) + for i, item := range projectflocks { + ids[i] = item.Id + } + + latestMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), s.approvalWorkflow, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for projectflocks: %+v", err) + } else if len(latestMap) > 0 { + for i := range projectflocks { + if approval, ok := latestMap[projectflocks[i].Id]; ok { + projectflocks[i].LatestApproval = approval + } + } + } + } + + 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 + } + + if s.ApprovalSvc != nil { + approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load approvals for projectflock %d: %+v", id, err) + } else if len(approvals) > 0 { + if projectflock.LatestApproval == nil { + latest := approvals[len(approvals)-1] + projectflock.LatestApproval = &latest + } + } else { + projectflock.LatestApproval = nil + } + } + + 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 + } + + category, ok := utils.NormalizeProjectFlockCategory(req.Category) + if !ok { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") + } + + if len(req.KandangIds) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required") + } + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, + commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, + commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, + commonSvc.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)) + } + } + + createBody := &entity.ProjectFlock{ + FlockId: req.FlockId, + AreaId: req.AreaId, + Category: string(category), + FcrId: req.FcrId, + LocationId: req.LocationId, + CreatedBy: 1, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + projectRepo := repository.NewProjectflockRepository(dbTransaction) + + period, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) + if err != nil { + return err + } + createBody.Period = period + + if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if err := s.attachKandangs(c.Context(), dbTransaction, createBody.Id, kandangIDs); err != nil { + return err + } + + actorID := uint(1) //TODO: Change From Auth + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err = approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + createBody.Id, + utils.ProjectFlockStepPengajuan, + &action, + actorID, + nil, + ) + return err + }) + + if err != nil { + 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 + } + + 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) + hasBodyChanges := false + var relationChecks []commonSvc.RelationCheck + + if req.FlockId != nil { + updateBody["flock_id"] = *req.FlockId + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ + Name: "Flock", + ID: req.FlockId, + Exists: relationExistsChecker[entity.Flock](s.Repository.DB()), + }) + } + if req.AreaId != nil { + updateBody["area_id"] = *req.AreaId + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ + Name: "Area", + ID: req.AreaId, + Exists: relationExistsChecker[entity.Area](s.Repository.DB()), + }) + } + if req.Category != nil { + if normalized, ok := utils.NormalizeProjectFlockCategory(*req.Category); ok { + updateBody["category"] = string(normalized) + } else { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") + } + } + if req.FcrId != nil { + updateBody["fcr_id"] = *req.FcrId + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ + Name: "FCR", + ID: req.FcrId, + Exists: relationExistsChecker[entity.Fcr](s.Repository.DB()), + }) + } + if req.LocationId != nil { + updateBody["location_id"] = *req.LocationId + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ + Name: "Location", + ID: req.LocationId, + Exists: relationExistsChecker[entity.Location](s.Repository.DB()), + }) + } + + if len(relationChecks) > 0 { + if err := commonSvc.EnsureRelations(c.Context(), relationChecks...); err != nil { + return nil, err + } + } + + var newKandangIDs []uint + hasKandangChanges := false + if req.KandangIds != nil { + hasKandangChanges = true + 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)) + } + } + } + + hasChanges := hasBodyChanges || hasKandangChanges + if !hasChanges { + return s.GetOne(c, id) + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + projectRepo := repository.NewProjectflockRepository(dbTransaction) + + if len(updateBody) > 0 { + if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return err + } + } else { + if _, err := projectRepo.GetByID(c.Context(), id, nil); err != nil { + return 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 _, kid := range newKandangIDs { + newSet[kid] = struct{}{} + } + + var toDetach []uint + for kid := range existingIDs { + if _, ok := newSet[kid]; !ok { + toDetach = append(toDetach, kid) + } + } + + var toAttach []uint + for kid := range newSet { + if _, ok := existingIDs[kid]; !ok { + toAttach = append(toAttach, kid) + } + } + + if len(toDetach) > 0 { + if err := s.detachKandangs(c.Context(), dbTransaction, id, toDetach, true); err != nil { + return err + } + } + + if len(toAttach) > 0 { + if err := s.attachKandangs(c.Context(), dbTransaction, id, toAttach); err != nil { + return err + } + } + } + + if hasChanges { + actorID := uint(1) //TODO: Change From Auth + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + if approvalSvc != nil { + latestBeforeReset, err := approvalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, nil) + if err != nil { + return err + } + shouldRecordUpdate := latestBeforeReset == nil || + latestBeforeReset.StepNumber != uint16(utils.ProjectFlockStepPengajuan) || + latestBeforeReset.Action == nil || + (latestBeforeReset.Action != nil && *latestBeforeReset.Action != entity.ApprovalActionUpdated) + + if shouldRecordUpdate { + action := entity.ApprovalActionUpdated + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + id, + utils.ProjectFlockStepPengajuan, + &action, + actorID, + nil, + ); err != nil { + return err + } + } + } + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to update projectflock %d: %+v", id, err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s projectflockService) Approval(c *fiber.Ctx, id uint, req *validation.Approve) (*entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + project, err := s.GetOne(c, id) + if err != nil { + s.Log.Errorf("Failed to fetch projectflock %d before approval: %+v", id, err) + return nil, err + } + + actorID := uint(1) // TODO: change from auth context + var action entity.ApprovalAction + switch strings.ToUpper(strings.TrimSpace(req.Action)) { + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + step := utils.ProjectFlockStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.ProjectFlockStepAktif + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + project.Id, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + + kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) + switch action { + case entity.ApprovalActionApproved: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + project.Id, + utils.KandangStatusActive, + ); err != nil { + return err + } + case entity.ApprovalActionRejected: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + project.Id, + utils.KandangStatusNonActive, + ); err != nil { + return err + } + } + + return nil + }) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to record approval for projectflock %d: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") + } + + 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") + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + if len(existing.Kandangs) > 0 { + ids := make([]uint, len(existing.Kandangs)) + for i, k := range existing.Kandangs { + ids[i] = k.Id + } + if err := s.detachKandangs(c.Context(), dbTransaction, id, ids, true); err != nil { + return err + } + } + + if err := repository.NewProjectflockRepository(dbTransaction).DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + return err + } + + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr + } + s.Log.Errorf("Failed to delete projectflock %d: %+v", id, err) + return err + } + + 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) + } +} + +func (s projectflockService) buildOrderExpressions(sortBy, sortOrder string) []string { + direction := "ASC" + if strings.ToLower(sortOrder) == "desc" { + direction = "DESC" + } + + switch sortBy { + case "area": + return []string{ + fmt.Sprintf("(SELECT name FROM areas WHERE areas.id = project_flocks.area_id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "location": + return []string{ + fmt.Sprintf("(SELECT name FROM locations WHERE locations.id = project_flocks.location_id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "kandangs": + return []string{ + fmt.Sprintf("(SELECT COUNT(*) FROM kandangs WHERE kandangs.project_flock_id = project_flocks.id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "period": + return []string{ + fmt.Sprintf("project_flocks.period %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + default: + return []string{ + "project_flocks.created_at DESC", + "project_flocks.updated_at DESC", + } + } +} + +func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint) error { + if len(kandangIDs) == 0 { + return nil + } + + if err := dbTransaction.Model(&entity.Kandang{}). + Where("id IN ?", kandangIDs). + Updates(map[string]any{ + "project_flock_id": projectFlockID, + "status": string(utils.KandangStatusPengajuan), + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + } + + pivotRepo := s.pivotRepoWithTx(dbTransaction) + records := make([]*entity.ProjectFlockKandang, len(kandangIDs)) + for i, id := range kandangIDs { + records[i] = &entity.ProjectFlockKandang{ + ProjectFlockId: projectFlockID, + KandangId: id, + } + } + if err := pivotRepo.CreateMany(ctx, records); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") + } + return nil +} + +func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint, resetStatus bool) error { + if len(kandangIDs) == 0 { + return nil + } + + updates := map[string]any{"project_flock_id": nil} + if resetStatus { + updates["status"] = string(utils.KandangStatusNonActive) + } + + if err := dbTransaction.Model(&entity.Kandang{}). + Where("id IN ?", kandangIDs). + Updates(updates).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") + } + + if err := s.pivotRepoWithTx(dbTransaction).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") + } + return nil +} + +func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { + if s.PivotRepo == nil { + return repository.NewProjectFlockKandangRepository(dbTransaction) + } + return s.PivotRepo.WithTx(dbTransaction) +} 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..00c9eab8 --- /dev/null +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -0,0 +1,36 @@ +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"` + Category string `json:"category" validate:"required_strict,oneof=growing laying GROWING LAYING"` + 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"` + Category *string `json:"category,omitempty" validate:"omitempty,oneof=growing laying GROWING LAYING"` + FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` + LocationId *uint `json:"location_id,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"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=area location kandangs period"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + AreaId uint `query:"area_id" validate:"omitempty,number,gt=0"` + LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` + Period int `query:"period" validate:"omitempty,number,gt=0"` + KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` +} + +type Approve struct { + Action string `json:"action" validate:"required_strict"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} 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..b41ef1e7 --- /dev/null +++ b/internal/modules/production/route.go @@ -0,0 +1,29 @@ +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" + + chickins "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins" + 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{}, + chickins.ChickinModule{}, + // MODULE REGISTRY + } + + for _, m := range allModules { + m.RegisterRoutes(group, db, validate) + } +} diff --git a/internal/modules/shared/repositories/audit-logs.repository.go b/internal/modules/shared/repositories/audit-logs.repository.go new file mode 100644 index 00000000..b247f3f2 --- /dev/null +++ b/internal/modules/shared/repositories/audit-logs.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type AuditLogRepository interface { + repository.BaseRepository[entity.AuditLog] +} + +type AuditLogRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.AuditLog] +} + +func NewAuditLogRepository(db *gorm.DB) AuditLogRepository { + return &AuditLogRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.AuditLog](db), + } +} diff --git a/internal/modules/shared/repositories/stock-availabilites.repository.go b/internal/modules/shared/repositories/stock-availabilites.repository.go new file mode 100644 index 00000000..9d3ae632 --- /dev/null +++ b/internal/modules/shared/repositories/stock-availabilites.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockAvailabilityRepository interface { + repository.BaseRepository[entity.StockAvailability] +} + +type StockAvailabilityRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockAvailability] +} + +func NewStockAvailabilityRepository(db *gorm.DB) StockAvailabilityRepository { + return &StockAvailabilityRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockAvailability](db), + } +} diff --git a/internal/modules/shared/stock-logs/repositories/stock-logs.repository.go b/internal/modules/shared/repositories/stock-logs.repository.go similarity index 100% rename from internal/modules/shared/stock-logs/repositories/stock-logs.repository.go rename to internal/modules/shared/repositories/stock-logs.repository.go diff --git a/internal/route/route.go b/internal/route/route.go index 82b48166..60f0fe26 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -12,6 +12,8 @@ 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" + approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals" // MODULE IMPORTS ) @@ -26,6 +28,8 @@ func Routes(app *fiber.App, db *gorm.DB) { master.MasterModule{}, constants.ConstantModule{}, inventory.InventoryModule{}, + production.ProductionModule{}, + approvals.ApprovalModule{}, // MODULE REGISTRY } diff --git a/internal/utils/approvals/util.approval_workflow.go b/internal/utils/approvals/util.approval_workflow.go new file mode 100644 index 00000000..78f1de8e --- /dev/null +++ b/internal/utils/approvals/util.approval_workflow.go @@ -0,0 +1,243 @@ +package approvals + +import ( + "errors" + "fmt" + "strings" + "sync" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type ApprovalStep uint16 + +type ApprovalWorkflowKey string + +func (k ApprovalWorkflowKey) String() string { + return string(k) +} + +type NextStepCallback func(current ApprovalStep, decision entity.ApprovalAction) (ApprovalStep, bool) + +var ( + approvalActions = map[entity.ApprovalAction]struct{}{ + entity.ApprovalActionApproved: {}, + entity.ApprovalActionRejected: {}, + entity.ApprovalActionCreated: {}, + entity.ApprovalActionUpdated: {}, + } + + approvalWorkflows = make(map[ApprovalWorkflowKey]map[ApprovalStep]string) + approvalWorkflowsMu sync.RWMutex +) + +// WorkflowConstants prepares the registered workflows for exposure via constants endpoints. +func WorkflowConstants() map[string]map[string]string { + approvalWorkflowsMu.RLock() + defer approvalWorkflowsMu.RUnlock() + + if len(approvalWorkflows) == 0 { + return nil + } + + result := make(map[string]map[string]string, len(approvalWorkflows)) + for workflow, steps := range approvalWorkflows { + if len(steps) == 0 { + continue + } + stepMap := make(map[string]string, len(steps)) + for step, label := range steps { + stepMap[fmt.Sprintf("%d", step)] = label + } + result[workflow.String()] = stepMap + } + if len(result) == 0 { + return nil + } + return result +} + +// RegisterWorkflowSteps stores the available steps for a workflow key (usually matching approvable type). +func RegisterWorkflowSteps(workflow ApprovalWorkflowKey, steps map[ApprovalStep]string) error { + workflowStr := strings.TrimSpace(workflow.String()) + if workflowStr == "" { + return errors.New("workflow key is required") + } + if len(steps) == 0 { + return fmt.Errorf("no steps defined for workflow %q", workflowStr) + } + + copied := make(map[ApprovalStep]string, len(steps)) + for step, label := range steps { + if step == 0 { + return fmt.Errorf("workflow %q contains step 0 which is not allowed", workflowStr) + } + trimmed := strings.TrimSpace(label) + if trimmed == "" { + return fmt.Errorf("workflow %q contains empty label for step %d", workflowStr, step) + } + copied[step] = trimmed + } + + approvalWorkflowsMu.Lock() + defer approvalWorkflowsMu.Unlock() + approvalWorkflows[ApprovalWorkflowKey(workflowStr)] = copied + return nil +} + +// WorkflowSteps returns the steps registered for the given workflow key. +func WorkflowSteps(workflow ApprovalWorkflowKey) map[ApprovalStep]string { + approvalWorkflowsMu.RLock() + defer approvalWorkflowsMu.RUnlock() + + workflowStr := strings.TrimSpace(workflow.String()) + if workflowStr == "" { + return nil + } + + steps, ok := approvalWorkflows[ApprovalWorkflowKey(workflowStr)] + if !ok || len(steps) == 0 { + return nil + } + + copied := make(map[ApprovalStep]string, len(steps)) + for step, label := range steps { + copied[step] = label + } + return copied +} + +// ApprovalStepName fetches the label for the target step inside the workflow. +func ApprovalStepName(workflow ApprovalWorkflowKey, step ApprovalStep) (string, bool) { + steps := WorkflowSteps(workflow) + if len(steps) == 0 { + return "", false + } + label, ok := steps[step] + return label, ok +} + +// ValidateApprovalStep ensures the workflow contains the provided step. +func ValidateApprovalStep(workflow ApprovalWorkflowKey, step ApprovalStep) error { + if _, ok := ApprovalStepName(workflow, step); ok { + return nil + } + return fmt.Errorf("invalid approval step %d for workflow %s", step, workflow) +} + +// IsValidApprovalAction reports whether the action is supported. +func IsValidApprovalAction(action entity.ApprovalAction) bool { + _, ok := approvalActions[action] + return ok +} + +// NewApproval creates an approval record for the given approvable target. +func NewApproval(workflow ApprovalWorkflowKey, approvableId uint, step ApprovalStep, action *entity.ApprovalAction, actorId uint, note *string) (*entity.Approval, error) { + if approvableId == 0 { + return nil, errors.New("approvable id is required") + } + + workflowStr := strings.TrimSpace(workflow.String()) + if workflowStr == "" { + return nil, errors.New("approval workflow key is required") + } + + key := ApprovalWorkflowKey(workflowStr) + + if err := ValidateApprovalStep(key, step); err != nil { + return nil, err + } + + var actionPtr *entity.ApprovalAction + if action != nil { + if !IsValidApprovalAction(*action) { + return nil, fmt.Errorf("invalid approval action %q", *action) + } + actionCopy := *action + actionPtr = &actionCopy + } + + if actorId == 0 { + return nil, errors.New("actor id is required") + } + + var notes *string + if note != nil { + trimmed := strings.TrimSpace(*note) + if trimmed != "" { + notes = &trimmed + } + } + + actor := actorId + var stepName string + if label, ok := ApprovalStepName(key, step); ok { + labelCopy := label + stepName = labelCopy + } + + return &entity.Approval{ + ApprovableType: workflowStr, + ApprovableId: approvableId, + StepNumber: uint16(step), + StepName: stepName, + Action: actionPtr, + Notes: notes, + ActionBy: &actor, + }, nil +} + +// SetApprovalAction updates the approval action, notes, and optionally advances to another step. +func SetApprovalAction(approval *entity.Approval, action entity.ApprovalAction, actorId uint, note *string, nextStep NextStepCallback) error { + if approval == nil { + return errors.New("approval is nil") + } + if !IsValidApprovalAction(action) { + return fmt.Errorf("invalid approval action %q", action) + } + if actorId == 0 { + return errors.New("actor id is required for approval decision") + } + + act := action + approval.Action = &act + approval.ActionBy = &actorId + + if note != nil { + trimmed := strings.TrimSpace(*note) + if trimmed == "" { + approval.Notes = nil + } else { + approval.Notes = &trimmed + } + } else { + approval.Notes = nil + } + + if nextStep != nil { + current := ApprovalStep(approval.StepNumber) + if proposed, ok := nextStep(current, action); ok { + if err := ValidateApprovalStep(ApprovalWorkflowKey(approval.ApprovableType), proposed); err != nil { + return err + } + approval.StepNumber = uint16(proposed) + } + } + + if label, ok := ApprovalStepName(ApprovalWorkflowKey(approval.ApprovableType), ApprovalStep(approval.StepNumber)); ok { + labelCopy := label + approval.StepName = labelCopy + } + + return nil +} + +// Approve marks the approval as approved by the given actor, applying the optional step callback. +func Approve(approval *entity.Approval, actorId uint, note *string, nextStep NextStepCallback) error { + return SetApprovalAction(approval, entity.ApprovalActionApproved, actorId, note, nextStep) +} + +// Reject marks the approval as rejected by the given actor, applying the optional step callback. +func Reject(approval *entity.Approval, actorId uint, note *string, nextStep NextStepCallback) error { + return SetApprovalAction(approval, entity.ApprovalActionRejected, actorId, note, nextStep) +} diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 941c8a5e..5ab236b0 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -1,6 +1,10 @@ package utils -import "strings" +import ( + "strings" + + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" +) // ------------------------------------------------------------------- // FlagType & Groups @@ -97,6 +101,45 @@ const ( SupplierCategorySapronak SupplierCategory = "SAPRONAK" ) +// ------------------------------------------------------------------- +// Kandang Status +// ------------------------------------------------------------------- + +type KandangStatus string + +const ( + KandangStatusNonActive KandangStatus = "NON_ACTIVE" + KandangStatusPengajuan KandangStatus = "PENGAJUAN" + KandangStatusActive KandangStatus = "ACTIVE" +) + +// ------------------------------------------------------------------- +// ProjectFlockCategory +// ------------------------------------------------------------------- + +type ProjectFlockCategory string + +const ( + ProjectFlockCategoryGrowing ProjectFlockCategory = "GROWING" + ProjectFlockCategoryLaying ProjectFlockCategory = "LAYING" +) + +// ------------------------------------------------------------------- +// Project Flock Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowProjectFlock approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PROJECT_FLOCKS") + ProjectFlockStepPengajuan approvalutils.ApprovalStep = 1 + ProjectFlockStepAktif approvalutils.ApprovalStep = 2 +) + +// projectFlockApprovalSteps keeps the workflow step definitions for project flock approvals. +var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{ + ProjectFlockStepPengajuan: "Pengajuan", + ProjectFlockStepAktif: "Aktif", +} + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -191,6 +234,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: @@ -199,6 +250,21 @@ func IsValidCustomerSupplierType(v string) bool { return false } +func NormalizeProjectFlockCategory(v string) (ProjectFlockCategory, bool) { + normalized := ProjectFlockCategory(strings.ToUpper(strings.TrimSpace(v))) + switch normalized { + case ProjectFlockCategoryGrowing, ProjectFlockCategoryLaying: + return normalized, true + default: + return "", false + } +} + +func IsValidProjectFlockCategory(v string) bool { + _, ok := NormalizeProjectFlockCategory(v) + return ok +} + func IsValidSupplierCategory(v string) bool { switch SupplierCategory(v) { case SupplierCategoryBOP, SupplierCategorySapronak: diff --git a/internal/utils/time.go b/internal/utils/time.go new file mode 100644 index 00000000..f57a3bb3 --- /dev/null +++ b/internal/utils/time.go @@ -0,0 +1,25 @@ +package utils + +import ( + "time" + "errors" +) + +// ParseDateString mengubah string "YYYY-MM-DD" menjadi time.Time +func ParseDateString(dateStr string) (time.Time, error) { + if dateStr == "" { + return time.Time{}, errors.New("date string is empty") + } + + parsed, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return time.Time{}, errors.New("invalid date format, expected YYYY-MM-DD") + } + + return parsed, nil +} + +// FormatDate mengubah time.Time menjadi string "YYYY-MM-DD" +func FormatDate(t time.Time) string { + return t.Format("2006-01-02") +} diff --git a/test/integration/master_data/kandang_test.go b/test/integration/master_data/kandang_test.go index e17c8ad5..6f7c5ce7 100644 --- a/test/integration/master_data/kandang_test.go +++ b/test/integration/master_data/kandang_test.go @@ -1,14 +1,18 @@ package test import ( + "encoding/json" "net/http" "testing" "github.com/gofiber/fiber/v2" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) 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 +25,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 +50,46 @@ 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) { + 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, + Category: string(utils.ProjectFlockCategoryGrowing), + 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..d43ddf15 100644 --- a/test/integration/master_data/master_data.go +++ b/test/integration/master_data/master_data.go @@ -40,6 +40,9 @@ func setupIntegrationApp(t *testing.T) (*fiber.App, *gorm.DB) { &entities.User{}, &entities.Area{}, &entities.Location{}, + &entities.Flock{}, + &entities.ProjectFlock{}, + &entities.ProjectFlockKandang{}, &entities.Kandang{}, &entities.Warehouse{}, &entities.Uom{}, @@ -152,6 +155,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, }) @@ -188,6 +192,15 @@ func fetchCustomer(t *testing.T, db *gorm.DB, id uint) entities.Customer { return customer } +func fetchKandang(t *testing.T, db *gorm.DB, id uint) entities.Kandang { + t.Helper() + var kandang entities.Kandang + if err := db.Preload("ProjectFlock").First(&kandang, id).Error; err != nil { + t.Fatalf("failed to fetch kandang: %v", err) + } + return kandang +} + func createSupplier(t *testing.T, app *fiber.App, name, alias, category string) uint { t.Helper() identifier := strings.ToLower(strings.ReplaceAll(name, " ", "_")) @@ -291,6 +304,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..60bb2d90 --- /dev/null +++ b/test/integration/master_data/project_flock_test.go @@ -0,0 +1,417 @@ +package test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/gofiber/fiber/v2" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +func TestProjectFlockSummary(t *testing.T) { + app, db := setupIntegrationApp(t) + + areaID := createArea(t, app, "Area Project") + locationID := createLocation(t, app, "Location Project", "Address", areaID) + flockID := createFlock(t, app, "Flock Summary") + 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, + "category": "growing", + "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"` + Category string `json:"category"` + Flock struct { + Id uint `json:"id"` + Name string `json:"name"` + } `json:"flock"` + Area struct { + Id uint `json:"id"` + Name string `json:"name"` + } `json:"area"` + 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.Category != string(utils.ProjectFlockCategoryGrowing) { + t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryGrowing, createResp.Data.Category) + } + 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 != string(utils.KandangStatusPengajuan) { + t.Fatalf("expected kandang status to be PENGAJUAN, got %s", createResp.Data.Kandangs[0].Status) + } + if createResp.Data.Period != 1 { + t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) + } + + createdKandang := fetchKandang(t, db, kandangID) + if createdKandang.Status != string(utils.KandangStatusPengajuan) { + t.Fatalf("expected kandang status in DB to be PENGAJUAN, got %s", createdKandang.Status) + } + + var pivotRecords []entities.ProjectFlockKandang + if err := db.Where("project_flock_id = ?", createResp.Data.Id).Find(&pivotRecords).Error; err != nil { + t.Fatalf("failed to fetch pivot records: %v", err) + } + if len(pivotRecords) != 1 { + t.Fatalf("expected 1 pivot record, got %d", len(pivotRecords)) + } + firstPivotRecord := pivotRecords[0] + if firstPivotRecord.KandangId != kandangID { + t.Fatalf("expected pivot kandang id %d, got %d", kandangID, firstPivotRecord.KandangId) + } + + secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) + secondPayload := map[string]any{ + "flock_id": flockID, + "area_id": areaID, + "category": "laying", + "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"` + Category string `json:"category"` + } `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) + } + if createRespSecond.Data.Category != string(utils.ProjectFlockCategoryLaying) { + t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryLaying, createRespSecond.Data.Category) + } + + pivotRecords = nil + if err := db.Where("project_flock_id = ?", createRespSecond.Data.Id).Find(&pivotRecords).Error; err != nil { + t.Fatalf("failed to fetch second pivot records: %v", err) + } + if len(pivotRecords) != 1 { + t.Fatalf("expected 1 pivot record for second project, got %d", len(pivotRecords)) + } + secondPivotRecord := pivotRecords[0] + if secondPivotRecord.KandangId != secondKandangID { + t.Fatalf("expected second pivot kandang id %d, got %d", secondKandangID, secondPivotRecord.KandangId) + } + + secondKandang := fetchKandang(t, db, secondKandangID) + if secondKandang.Status != string(utils.KandangStatusPengajuan) { + t.Fatalf("expected second kandang status in DB to be PENGAJUAN, got %s", secondKandang.Status) + } + + 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)) + } + + firstKandang := fetchKandang(t, db, kandangID) + if firstKandang.ProjectFlockId != nil { + t.Fatalf("expected project_flock_id to be nil after delete, got %v", *firstKandang.ProjectFlockId) + } + if firstKandang.Status != string(utils.KandangStatusNonActive) { + t.Fatalf("expected kandang status to revert to NON_ACTIVE, got %s", firstKandang.Status) + } + + var remainingFirst int64 + if err := db.Model(&entities.ProjectFlockKandang{}). + Where("project_flock_id = ? AND kandang_id = ?", createResp.Data.Id, kandangID). + Count(&remainingFirst).Error; err != nil { + t.Fatalf("failed to count first pivot records after delete: %v", err) + } + if remainingFirst != 0 { + t.Fatalf("expected no pivot records remaining after delete, found %d", remainingFirst) + } + + 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)) + } + + secondKandang = fetchKandang(t, db, secondKandangID) + if secondKandang.ProjectFlockId != nil { + t.Fatalf("expected second project_flock_id to be nil after delete, got %v", *secondKandang.ProjectFlockId) + } + if secondKandang.Status != string(utils.KandangStatusNonActive) { + t.Fatalf("expected second kandang status to revert to NON_ACTIVE, got %s", secondKandang.Status) + } + + var remainingSecond int64 + if err := db.Model(&entities.ProjectFlockKandang{}). + Where("project_flock_id = ? AND kandang_id = ?", createRespSecond.Data.Id, secondKandangID). + Count(&remainingSecond).Error; err != nil { + t.Fatalf("failed to count second pivot records after delete: %v", err) + } + if remainingSecond != 0 { + t.Fatalf("expected no second pivot records remaining after delete, found %d", remainingSecond) + } + + 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) +} + +func TestProjectFlockSearchByRelatedFields(t *testing.T) { + app, _ := setupIntegrationApp(t) + + areaID := createArea(t, app, "Area Search Target") + locationID := createLocation(t, app, "Location Search Target", "Location Address Target", areaID) + flockID := createFlock(t, app, "Flock Search Target") + fcrID := createFcr(t, app, "FCR Search Target", []map[string]any{ + {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, + }) + kandangID := createKandang(t, app, "Kandang Search Target", locationID, 1) + + createPayload := map[string]any{ + "flock_id": flockID, + "area_id": areaID, + "category": "growing", + "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"` + } `json:"data"` + } + if err := json.Unmarshal(body, &createResp); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + + searchTerms := []string{ + "Flock Search Target", + "Area Search Target", + string(utils.ProjectFlockCategoryGrowing), + "growing", + "FCR Search Target", + "Kandang Search Target", + "Location Search Target", + "Location Address Target", + "Tester", + "1", + } + + for _, term := range searchTerms { + path := "/api/production/project_flocks?search=" + url.QueryEscape(term) + resp, body := doJSONRequest(t, app, http.MethodGet, path, nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when searching for %q, got %d: %s", term, resp.StatusCode, string(body)) + } + + var listResp struct { + Data []struct { + Id uint `json:"id"` + } `json:"data"` + Meta struct { + TotalResults int64 `json:"total_results"` + } `json:"meta"` + } + if err := json.Unmarshal(body, &listResp); err != nil { + t.Fatalf("failed to parse list response for %q: %v", term, err) + } + if listResp.Meta.TotalResults == 0 { + t.Fatalf("expected at least one result when searching for %q", term) + } + if len(listResp.Data) == 0 { + t.Fatalf("expected data when searching for %q", term) + } + if listResp.Data[0].Id != createResp.Data.Id { + t.Fatalf("expected project flock id %d for search term %q, got %d", createResp.Data.Id, term, listResp.Data[0].Id) + } + } +} + +func TestProjectFlockSorting(t *testing.T) { + app, _ := setupIntegrationApp(t) + + areaA := createArea(t, app, "Area Alpha") + areaB := createArea(t, app, "Area Beta") + + locationA := createLocation(t, app, "Location Alpha", "Address Alpha", areaA) + locationB := createLocation(t, app, "Location Beta", "Address Beta", areaB) + + flockOne := createFlock(t, app, "Flock Sort One") + flockTwo := createFlock(t, app, "Flock Sort Two") + + fcrID := createFcr(t, app, "FCR Sort", []map[string]any{ + {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, + }) + + kandangOne := createKandang(t, app, "Kandang Sort One", locationA, 1) + kandangTwo := createKandang(t, app, "Kandang Sort Two", locationB, 1) + kandangThree := createKandang(t, app, "Kandang Sort Three", locationB, 1) + + projectOnePayload := map[string]any{ + "flock_id": flockOne, + "area_id": areaA, + "category": "growing", + "fcr_id": fcrID, + "location_id": locationA, + "kandang_ids": []uint{kandangOne}, + } + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectOnePayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 for project one, got %d: %s", resp.StatusCode, string(body)) + } + projectOneID := parseProjectFlockID(t, body) + + projectTwoPayload := map[string]any{ + "flock_id": flockTwo, + "area_id": areaB, + "category": "laying", + "fcr_id": fcrID, + "location_id": locationB, + "kandang_ids": []uint{kandangTwo, kandangThree}, + } + resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectTwoPayload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 for project two, got %d: %s", resp.StatusCode, string(body)) + } + projectTwoID := parseProjectFlockID(t, body) + + updatePeriodPayload := map[string]any{"period": 5} + resp, body = doJSONRequest(t, app, http.MethodPatch, "/api/production/project_flocks/"+uintToString(projectTwoID), updatePeriodPayload) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating period, got %d: %s", resp.StatusCode, string(body)) + } + + assertOrder := func(t *testing.T, app *fiber.App, query string, expectedFirst uint) { + t.Helper() + resp, body := doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks?"+query, nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 for query %q, got %d: %s", query, resp.StatusCode, string(body)) + } + var listResp struct { + Data []struct { + Id uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &listResp); err != nil { + t.Fatalf("failed to parse list response for %q: %v", query, err) + } + if len(listResp.Data) == 0 { + t.Fatalf("expected data for query %q", query) + } + if listResp.Data[0].Id != expectedFirst { + t.Fatalf("expected first id %d for query %q, got %d", expectedFirst, query, listResp.Data[0].Id) + } + } + + assertOrder(t, app, "sort_by=area&sort_order=asc", projectOneID) + assertOrder(t, app, "sort_by=location&sort_order=desc", projectTwoID) + assertOrder(t, app, "sort_by=period&sort_order=desc", projectTwoID) + assertOrder(t, app, "sort_by=kandangs&sort_order=desc", projectTwoID) + assertOrder(t, app, "sort_by=kandangs&sort_order=asc", projectOneID) +} + +func parseProjectFlockID(t *testing.T, body []byte) uint { + t.Helper() + var resp struct { + Data struct { + Id uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to parse project flock response: %v", err) + } + return resp.Data.Id +} diff --git a/tools/templates/validation.tmpl b/tools/templates/validation.tmpl index 3aa587eb..031b76c5 100644 --- a/tools/templates/validation.tmpl +++ b/tools/templates/validation.tmpl @@ -9,8 +9,8 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Search string `query:"search" validate:"omitempty,max=50"` } {{end}}