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/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/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/migrations/20251022024829_create_project_chickin_details.down.sql b/internal/database/migrations/20251022024829_create_project_chickin_details.down.sql new file mode 100644 index 00000000..521e57bf --- /dev/null +++ b/internal/database/migrations/20251022024829_create_project_chickin_details.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_chickin_details; \ No newline at end of file diff --git a/internal/database/migrations/20251022024829_create_project_chickin_details.up.sql b/internal/database/migrations/20251022024829_create_project_chickin_details.up.sql new file mode 100644 index 00000000..349086ba --- /dev/null +++ b/internal/database/migrations/20251022024829_create_project_chickin_details.up.sql @@ -0,0 +1,45 @@ +CREATE TABLE IF NOT EXISTS project_chickin_details ( + id BIGSERIAL PRIMARY KEY, + project_chickin_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + quantity NUMERIC(15, 3) NOT NULL, + 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_chickins') THEN + ALTER TABLE project_chickin_details + ADD CONSTRAINT fk_project_chickin_id + FOREIGN KEY (project_chickin_id) + REFERENCES project_chickins(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + ALTER TABLE project_chickin_details + ADD CONSTRAINT fk_product_warehouse_id + FOREIGN KEY (product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE project_chickin_details + 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_chickin_details_project_chickin_id ON project_chickin_details (project_chickin_id); + +CREATE INDEX IF NOT EXISTS idx_project_chickin_details_product_warehouse_id ON project_chickin_details (product_warehouse_id); + +CREATE INDEX IF NOT EXISTS idx_project_chickin_details_created_by ON project_chickin_details (created_by); \ No newline at end of file diff --git a/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql b/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql deleted file mode 100644 index f3cb3ddf..00000000 --- a/internal/database/migrations/20251107120000_add_project_flock_period_unique.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP INDEX IF EXISTS project_flocks_flock_period_unique; diff --git a/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql b/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql deleted file mode 100644 index 40cebe2d..00000000 --- a/internal/database/migrations/20251107120000_add_project_flock_period_unique.up.sql +++ /dev/null @@ -1,3 +0,0 @@ -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 aa70b084..791cfddb 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" ) @@ -50,7 +51,7 @@ func Run(db *gorm.DB) error { return err } - projectFlocks, err := seedProjectFlocks(tx, adminID, flocks, areas, productCategories, fcrs, locations) + projectFlocks, err := seedProjectFlocks(tx, adminID, flocks, areas, fcrs, locations) if err != nil { return err } @@ -242,33 +243,33 @@ func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, productCategories, fcrs, locations map[string]uint) (map[string]uint, error) { +func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, fcrs, locations map[string]uint) (map[string]uint, error) { seeds := []struct { - Key string - Flock string - Area string - ProductCategory string - Fcr string - Location string - Period int + Key string + Flock string + Area string + Category utils.ProjectFlockCategory + Fcr string + Location string + Period int }{ { - Key: "Singaparna Period 1", - Flock: "Flock Priangan", - Area: "Priangan", - ProductCategory: "Day Old Chick", - Fcr: "FCR Layer", - Location: "Singaparna", - Period: 1, + 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", - ProductCategory: "Day Old Chick", - Fcr: "FCR Layer", - Location: "Cikaum", - Period: 1, + Key: "Cikaum Period 1", + Flock: "Flock Banten", + Area: "Banten", + Category: utils.ProjectFlockCategoryGrowing, + Fcr: "FCR Layer", + Location: "Cikaum", + Period: 1, }, } @@ -283,10 +284,6 @@ func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, productCatego if !ok { return nil, fmt.Errorf("area %s not seeded", seed.Area) } - categoryID, ok := productCategories[seed.ProductCategory] - if !ok { - return nil, fmt.Errorf("product category %s not seeded", seed.ProductCategory) - } fcrID, ok := fcrs[seed.Fcr] if !ok { return nil, fmt.Errorf("fcr %s not seeded", seed.Fcr) @@ -297,17 +294,17 @@ func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, productCatego } var projectFlock entity.ProjectFlock - err := tx.Where("flock_id = ? AND area_id = ? AND product_category_id = ? AND fcr_id = ? AND location_id = ? AND period = ?", - flockID, areaID, categoryID, fcrID, locationID, seed.Period).First(&projectFlock).Error + 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, - ProductCategoryId: categoryID, - FcrId: fcrID, - LocationId: locationID, - Period: seed.Period, - CreatedBy: createdBy, + 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 @@ -316,22 +313,78 @@ func seedProjectFlocks(tx *gorm.DB, createdBy uint, flocks, areas, productCatego return nil, err } else { if err := tx.Model(&entity.ProjectFlock{}).Where("id = ?", projectFlock.Id).Updates(map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationID, - "period": seed.Period, + "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 @@ -341,9 +394,9 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users ProjectFlockKey *string }{ {Name: "Singaparna 1", Status: utils.KandangStatusActive, Location: "Singaparna", PicKey: "admin", ProjectFlockKey: strPtr("Singaparna Period 1")}, - {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin", ProjectFlockKey: strPtr("Singaparna Period 1")}, + {Name: "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.KandangStatusPengajuan, Location: "Cikaum", PicKey: "admin"}, + {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, } result := make(map[string]uint, len(seeds)) @@ -381,7 +434,7 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users if err := tx.Create(&kandang).Error; err != nil { return nil, err } - if err := syncPivotRelation(tx, projectFlockID, kandang.Id, createdBy); err != nil { + if err := syncPivotRelation(tx, projectFlockID, kandang.Id); err != nil { return nil, err } } else if err != nil { @@ -400,7 +453,7 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users 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, createdBy); err != nil { + if err := syncPivotRelation(tx, projectFlockID, kandang.Id); err != nil { return nil, err } } @@ -410,25 +463,24 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users return result, nil } -func syncPivotRelation(tx *gorm.DB, projectFlockID *uint, kandangID uint, createdBy uint) error { +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, createdBy) + return ensureActivePivot(tx, *projectFlockID, kandangID) } func detachActivePivot(tx *gorm.DB, kandangID uint) error { - return tx.Model(&entity.ProjectFlockKandang{}). - Where("kandang_id = ? AND detached_at IS NULL", kandangID). - Updates(map[string]any{"detached_at": time.Now()}).Error + return tx.Where("kandang_id = ?", kandangID). + Delete(&entity.ProjectFlockKandang{}).Error } -func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID, createdBy uint) error { +func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID uint) error { var pivot entity.ProjectFlockKandang - err := tx.Where("project_flock_id = ? AND kandang_id = ? AND detached_at IS NULL", projectFlockID, kandangID). + err := tx.Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). First(&pivot).Error if err == nil { return nil @@ -439,7 +491,6 @@ func ensureActivePivot(tx *gorm.DB, projectFlockID, kandangID, createdBy uint) e newRecord := entity.ProjectFlockKandang{ ProjectFlockId: projectFlockID, KandangId: kandangID, - CreatedBy: createdBy, } return tx.Create(&newRecord).Error } @@ -1119,7 +1170,6 @@ func seedChickin(tx *gorm.DB, createdBy uint) error { 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) { @@ -1147,6 +1197,84 @@ func seedChickin(tx *gorm.DB, createdBy uint) error { return err } } + + var pfk entity.ProjectFlockKandang + if err := tx.Where("id = ?", seed.ProjectFlockKandangId).First(&pfk).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // no pivot found; skip creating details + continue + } + return err + } + + var warehouse entity.Warehouse + if err := tx.Where("kandang_id = ?", pfk.KandangId).First(&warehouse).Error; err != nil { + // if warehouse not found, cannot create details + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + return err + } + + var productWarehouses []entity.ProductWarehouse + err = tx.Table("product_warehouses"). + Select("product_warehouses.*"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). + Order("product_warehouses.created_at DESC"). + Find(&productWarehouses).Error + if err != nil { + return err + } + + // If no product warehouses found, keep existing chickin.Quantity and skip details + if len(productWarehouses) == 0 { + continue + } + + // sum all pw quantities and set chickin.Quantity to that total (mimic CreateOne) + totalQty := 0.0 + for _, pw := range productWarehouses { + totalQty += pw.Quantity + } + + if chickin.Quantity != totalQty { + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Update("quantity", totalQty).Error; err != nil { + return err + } + chickin.Quantity = totalQty + } + + for _, pw := range productWarehouses { + // ensure detail exists or create it with full pw.Quantity + var detail entity.ProjectChickinDetail + err = tx.Where("project_chickin_id = ? AND product_warehouse_id = ?", chickin.Id, pw.Id).First(&detail).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + detail = entity.ProjectChickinDetail{ + ProjectChickinId: chickin.Id, + ProductWarehouseId: pw.Id, + Quantity: pw.Quantity, + CreatedBy: createdBy, + } + if err := tx.Create(&detail).Error; err != nil { + return err + } + } else if err != nil { + return err + } else { + if detail.Quantity != pw.Quantity { + if err := tx.Model(&entity.ProjectChickinDetail{}).Where("id = ?", detail.Id).Update("quantity", pw.Quantity).Error; err != nil { + return err + } + } + } + + // zero out pw quantity + if err := tx.Model(&entity.ProductWarehouse{}).Where("id = ?", pw.Id).Update("quantity", 0).Error; err != nil { + return err + } + } } return nil diff --git a/internal/entities/ProjectChickinDetail.go b/internal/entities/ProjectChickinDetail.go new file mode 100644 index 00000000..c11cb9da --- /dev/null +++ b/internal/entities/ProjectChickinDetail.go @@ -0,0 +1,22 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ProjectChickinDetail struct { + Id uint `gorm:"primaryKey"` + ProjectChickinId uint `gorm:"column:project_chickin_id;not null"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + Quantity float64 `gorm:"type:numeric(15,3);not null"` + CreatedBy uint `gorm:"column:created_by;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + ProjectChickin *ProjectChickin `gorm:"foreignKey:ProjectChickinId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` +} 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 deleted file mode 100644 index 3b770125..00000000 --- a/internal/entities/audit_log.go +++ /dev/null @@ -1,18 +0,0 @@ -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/projectfloc.go b/internal/entities/projectfloc.go deleted file mode 100644 index 2d581e84..00000000 --- a/internal/entities/projectfloc.go +++ /dev/null @@ -1,29 +0,0 @@ -package entities - -import ( - "time" - - "gorm.io/gorm" -) - -type ProjectFlock struct { - Id uint `gorm:"primaryKey"` - FlockId uint `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:1"` - AreaId uint `gorm:"not null"` - ProductCategoryId uint `gorm:"not null"` - FcrId uint `gorm:"not null"` - LocationId uint `gorm:"not null"` - Period int `gorm:"not null;uniqueIndex:idx_project_flocks_flock_period,priority:2"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Flock Flock `gorm:"foreignKey:FlockId;references:Id"` - Area Area `gorm:"foreignKey:AreaId;references:Id"` - ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` - Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` - Location Location `gorm:"foreignKey:LocationId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Kandangs []Kandang `gorm:"foreignKey:ProjectFlockId;references:Id"` - KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;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 index 0014a815..1c29c22e 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -4,14 +4,9 @@ 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_active,priority:1,where:detached_at IS NULL"` - KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_active,priority:2,where:detached_at IS NULL"` - CreatedBy uint `gorm:"not null"` - AssignedAt time.Time `gorm:"autoCreateTime"` - DetachedAt *time.Time `gorm:"index"` + 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"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/entities/stock_availabilites.go b/internal/entities/stock_availabilites.go deleted file mode 100644 index ec24d36b..00000000 --- a/internal/entities/stock_availabilites.go +++ /dev/null @@ -1,26 +0,0 @@ -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/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/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 90642f6c..dd6c0068 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -266,6 +266,24 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) + // create stock log for decrease (source) + beforeQty := sourcePW.Quantity + product.ProductQty // sourcePW already decreased + decreaseLog := &entity.StockLog{ + TransactionType: entity.TransactionTypeDecrease, + Quantity: product.ProductQty, + BeforeQuantity: beforeQty, + AfterQuantity: sourcePW.Quantity, + LogType: entity.LogTypeTransfer, + LogId: uint(entityTransfer.Id), + Note: "", + ProductWarehouseId: sourcePW.Id, + CreatedBy: 1, + } + if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log decrease: %+v", err) + return err + } + // Tambah stok di gudang tujuan destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), @@ -296,6 +314,24 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) + // create stock log for increase (destination) + beforeDestQty := destPW.Quantity - product.ProductQty + increaseLog := &entity.StockLog{ + TransactionType: entity.TransactionTypeIncrease, + Quantity: product.ProductQty, + BeforeQuantity: beforeDestQty, + AfterQuantity: destPW.Quantity, + LogType: entity.LogTypeTransfer, + LogId: uint(entityTransfer.Id), + Note: "", + ProductWarehouseId: destPW.Id, + CreatedBy: 1, + } + if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log increase: %+v", err) + return err + } + } return nil diff --git a/internal/modules/master/flocks/repositories/flock.repository.go b/internal/modules/master/flocks/repositories/flock.repository.go index 12f269fc..006fe541 100644 --- a/internal/modules/master/flocks/repositories/flock.repository.go +++ b/internal/modules/master/flocks/repositories/flock.repository.go @@ -1,21 +1,30 @@ package repository import ( - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "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/services/flock.service.go b/internal/modules/master/flocks/services/flock.service.go index 4c3c9b26..ad086920 100644 --- a/internal/modules/master/flocks/services/flock.service.go +++ b/internal/modules/master/flocks/services/flock.service.go @@ -2,6 +2,8 @@ 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" @@ -79,8 +81,22 @@ func (s *flockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity. 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: req.Name, + Name: name, CreatedBy: 1, } @@ -100,7 +116,20 @@ func (s flockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) ( updateBody := make(map[string]any) if req.Name != nil { - updateBody["name"] = *req.Name + 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 { diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go index bcb03854..22546339 100644 --- a/internal/modules/master/kandangs/repositories/kandang.repository.go +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -17,6 +17,7 @@ type KandangRepository interface { 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 { @@ -81,3 +82,10 @@ func (r *KandangRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, pr } 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/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index 96115b58..193257b6 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -9,7 +9,6 @@ import ( 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" - productCategoryBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" userBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -24,13 +23,13 @@ type ChickinBaseDTO struct { } type ProjectFlockDTO struct { - Id uint `json:"id"` - Period int `json:"period"` - Flock *flockBaseDTO.FlockBaseDTO `json:"flock"` - ProductCategory *productCategoryBaseDTO.ProductCategoryBaseDTO `json:"product_category"` - Area *areaBaseDTO.AreaBaseDTO `json:"area"` - Fcr *fcrBaseDTO.FcrBaseDTO `json:"fcr"` - Location *locationBaseDTO.LocationBaseDTO `json:"location"` + 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 { @@ -71,11 +70,6 @@ func ToFlockDTO(e entity.Flock) flockBaseDTO.FlockBaseDTO { func ToKandangDTO(e entity.Kandang) kandangBaseDTO.KandangBaseDTO { return kandangBaseDTO.ToKandangBaseDTO(e) } - -func ToProductCategoryDTO(e entity.ProductCategory) productCategoryBaseDTO.ProductCategoryBaseDTO { - return productCategoryBaseDTO.ToProductCategoryBaseDTO(e) -} - func ToAreaDTO(e entity.Area) areaBaseDTO.AreaBaseDTO { return areaBaseDTO.ToAreaBaseDTO(e) } @@ -98,11 +92,6 @@ func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { mapped := flockBaseDTO.ToFlockBaseDTO(e.Flock) flock = &mapped } - var productCategory *productCategoryBaseDTO.ProductCategoryBaseDTO - if e.ProductCategory.Id != 0 { - mapped := productCategoryBaseDTO.ToProductCategoryBaseDTO(e.ProductCategory) - productCategory = &mapped - } var area *areaBaseDTO.AreaBaseDTO if e.Area.Id != 0 { mapped := areaBaseDTO.ToAreaBaseDTO(e.Area) @@ -119,13 +108,13 @@ func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { location = &mapped } return ProjectFlockDTO{ - Id: e.Id, - Period: e.Period, - Flock: flock, - ProductCategory: productCategory, - Area: area, - Fcr: fcr, - Location: location, + Id: e.Id, + Period: e.Period, + Category: e.Category, + Flock: flock, + Area: area, + Fcr: fcr, + Location: location, } } diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 116e2fbb..f1e0baea 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -10,7 +10,6 @@ import ( 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" @@ -22,8 +21,9 @@ type ChickinModule struct{} func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { chickinRepo := rChickin.NewChickinRepository(db) + chickinDetailRepo := rChickin.NewChickinDetailRepository(db) kandangRepo := rKandang.NewKandangRepository(db) - auditlogrepo := rAuditLog.NewAuditLogRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectflockkandangrepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) @@ -32,7 +32,7 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * userRepo := rUser.NewUserRepository(db) - chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, auditlogrepo, projectflockkandangrepo, projectflockpopulationrepo, validate) + chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, validate) userService := sUser.NewUserService(userRepo, validate) ChickinRoutes(router, userService, chickinService) diff --git a/internal/modules/production/chickins/repositories/project_chickin_detail.repository.go b/internal/modules/production/chickins/repositories/project_chickin_detail.repository.go new file mode 100644 index 00000000..42c267ec --- /dev/null +++ b/internal/modules/production/chickins/repositories/project_chickin_detail.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 ProjectChickinDetailRepository interface { + repository.BaseRepository[entity.ProjectChickinDetail] +} + +type ChickinDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectChickinDetail] +} + +func NewChickinDetailRepository(db *gorm.DB) ProjectChickinDetailRepository { + return &ChickinDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectChickinDetail](db), + } +} diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 46bc8069..0df1b6b5 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -8,11 +8,8 @@ import ( 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" @@ -38,12 +35,12 @@ type chickinService struct { WarehouseRepo rWarehouse.WarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository ProjectFlockRepo rProjectFlock.ProjectflockRepository - AuditLogRepo AuditLogRepo.AuditLogRepository - ProjectflockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository + ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository + ProjectChickinDetailRepo repository.ProjectChickinDetailRepository } -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 { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -52,9 +49,9 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, ProjectFlockRepo: projectFlockRepo, - AuditLogRepo: auditLogRepo, ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, + ProjectChickinDetailRepo: projectChickinDetailRepo, } } @@ -67,7 +64,6 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { Preload("ProjectFlockKandang.Kandang.Pic"). Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock.Flock"). - Preload("ProjectFlockKandang.ProjectFlock.ProductCategory"). Preload("ProjectFlockKandang.ProjectFlock.Area"). Preload("ProjectFlockKandang.ProjectFlock.Fcr"). Preload("ProjectFlockKandang.ProjectFlock.Location"). @@ -113,7 +109,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - projectflockkandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), 1) + projectflockkandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId) if err != nil { s.Log.Errorf("Failed to get projectflock kandang: %+v", err) return nil, err @@ -125,23 +121,12 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - projectFlock, err := s.ProjectFlockRepo.GetByID( - c.Context(), - projectflockkandang.ProjectFlockId, - func(db *gorm.DB) *gorm.DB { - return db.Preload("ProductCategory") - }, - ) - 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.ProductCategory.Code, warehouse.Id). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). Order("created_at DESC"). Find(&productWarehouses).Error if err != nil { @@ -169,7 +154,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid ChickInDate format") } newChickin := &entity.ProjectChickin{ - ProjectFlockKandangId: projectflockkandang.ProjectFlockId, + ProjectFlockKandangId: projectflockkandang.Id, ChickInDate: chickinDate, Quantity: totalQuantity, Note: "", @@ -190,6 +175,19 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) return nil, err } + + // add ke detail chickin + newChickinDetail := &entity.ProjectChickinDetail{ + ProjectChickinId: newChickin.Id, + ProductWarehouseId: pw.Id, + Quantity: pw.Quantity, + CreatedBy: 1, // todo: ganti dengan user login + } + err = s.ProjectChickinDetailRepo.CreateOne(c.Context(), newChickinDetail, nil) + if err != nil { + s.Log.Errorf("Failed to create chickin detail: %+v", err) + return nil, err + } } existingPopulation, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) @@ -250,85 +248,142 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { - // todo: cek apakah chickin sudah di approve atau belum + db := s.Repository.DB() - chickin, err := s.Repository.GetByID(c.Context(), id, nil) + tx := db.WithContext(c.Context()).Begin() + if tx.Error != nil { + s.Log.Errorf("Failed to begin transaction: %+v", tx.Error) + return tx.Error + } + rollback := func(err error) error { + if rerr := tx.Rollback().Error; rerr != nil { + s.Log.Errorf("Rollback failed: %+v", rerr) + } + return err + } + + chickinRepoTx := s.Repository.WithTx(tx) + pfkRepoTx := s.ProjectflockKandangRepo.WithTx(tx) + productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(tx) + + chickin, err := chickinRepoTx.GetByID(c.Context(), id, nil) if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + return rollback(fiber.NewError(fiber.StatusNotFound, "Chickin not found")) } if err != nil { s.Log.Errorf("Failed get chickin by id: %+v", err) - return err + return rollback(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 { + var population entity.ProjectFlockPopulation + if err := tx.WithContext(c.Context()).Where("project_flock_kandang_id = ?", chickin.ProjectFlockKandangId).First(&population).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + return rollback(fiber.NewError(fiber.StatusNotFound, "Project flock population not found")) + } + s.Log.Errorf("Failed to get project flock population: %+v", err) + return rollback(err) + } + + newReserved := population.ReservedQuantity - chickin.Quantity + if newReserved < 0 { + newReserved = 0 + } + if err := tx.WithContext(c.Context()).Model(&entity.ProjectFlockPopulation{}).Where("id = ?", population.Id).Updates(map[string]any{"reserved_quantity": newReserved}).Error; err != nil { + s.Log.Errorf("Failed to update project flock population: %+v", err) + return rollback(err) + } + + // helper: restore quantities from details; returns (restored bool, error) + restoreFromDetails := func() (bool, error) { + var details []entity.ProjectChickinDetail + if err := tx.WithContext(c.Context()).Where("project_chickin_id = ?", chickin.Id).Find(&details).Error; err != nil { + return false, err + } + if len(details) == 0 { + return false, nil + } + + for _, d := range details { + var pw entity.ProductWarehouse + if err := tx.WithContext(c.Context()).Where("id = ?", d.ProductWarehouseId).First(&pw).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + + continue + } + return false, err + } + + updatedQuantity := pw.Quantity + d.Quantity + if err := productWarehouseRepoTx.PatchOne(c.Context(), pw.Id, map[string]any{"quantity": updatedQuantity}, nil); err != nil { + return false, err + } + } + + if err := tx.WithContext(c.Context()).Where("project_chickin_id = ?", chickin.Id).Delete(&entity.ProjectChickinDetail{}).Error; err != nil { + return false, err + } + return true, nil + } + + restored, err := restoreFromDetails() + if err != nil { + s.Log.Errorf("Failed to restore from chickin details: %+v", err) + return rollback(err) + } + + if !restored { + + projectflockkandang, err := pfkRepoTx.GetByID(c.Context(), population.ProjectFlockKandangId) + if err != nil { + s.Log.Errorf("Failed to get projectflock kandang: %+v", err) + return rollback(err) + } + + var warehouse entity.Warehouse + if err := tx.WithContext(c.Context()).Where("kandang_id = ?", projectflockkandang.KandangId).First(&warehouse).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return rollback(fiber.NewError(fiber.StatusNotFound, "Warehouse not found for kandang")) + } + s.Log.Errorf("Failed to get warehouse: %+v", err) + return rollback(err) + } + + var productWarehouse entity.ProductWarehouse + err = tx.WithContext(c.Context()).Table("product_warehouses"). + Select("product_warehouses.*"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). + Order("product_warehouses.created_at DESC"). + First(&productWarehouse).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return rollback(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 rollback(err) + } + + updatedQuantity := productWarehouse.Quantity + chickin.Quantity + if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouse.Id, map[string]any{"quantity": updatedQuantity}, nil); err != nil { + s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) + return rollback(err) + } + } + + // delete chickin (single place) + if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return rollback(fiber.NewError(fiber.StatusNotFound, "Chickin not found")) } s.Log.Errorf("Failed to delete chickin: %+v", err) - return err + return rollback(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.Preload("ProductCategory") - }, - ) - - 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.ProductCategory.Code, 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 + if err := tx.Commit().Error; err != nil { + s.Log.Errorf("Failed to commit transaction: %+v", err) + return rollback(err) } return nil diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index a1f2e263..ca60d5df 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -190,6 +190,37 @@ func (u *ProjectflockController) DeleteOne(c *fiber.Ctx) error { }) } +func (u *ProjectflockController) Approval(c *fiber.Ctx) error { + req := new(validation.Approve) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + results, err := u.ProjectflockService.Approval(c, req) + if err != nil { + return err + } + + var ( + data interface{} + message = "Submit projectflock approval successfully" + ) + if len(results) == 1 { + data = dto.ToProjectFlockListDTO(results[0]) + } else { + message = "Submit projectflock approvals successfully" + data = dto.ToProjectFlockListDTOs(results) + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: message, + Data: data, + }) +} + func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { param := c.Params("flock_id") @@ -213,3 +244,19 @@ func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { Data: responseBody, }) } + +func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { + projectFlockIdStr := c.Query("project_flock_id", "") + kandangIdStr := c.Query("kandang_id", "") + + result, err := u.ProjectflockService.GetProjectFlockKandangByParams(c, "", projectFlockIdStr, kandangIdStr) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{Code: fiber.StatusOK, + Status: "success", + Message: "Get projectflock kandang successfully", + Data: dto.ToProjectFlockKandangDTO(*result)}) +} diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index a42caebf..dff3bc61 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -4,84 +4,43 @@ 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"` - // FlockId uint `json:"flock_id"` - // AreaId uint `json:"area_id"` - // ProductCategoryId uint `json:"product_category_id"` - // FcrId uint `json:"fcr_id"` - // LocationId uint `json:"location_id"` - Period int `json:"period"` -} - -func ToProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { - return ProjectFlockBaseDTO{ - Id: e.Id, - // FlockId: e.FlockId, - // AreaId: e.AreaId, - // ProductCategoryId: e.ProductCategoryId, - // FcrId: e.FcrId, - // LocationId: e.LocationId, - Period: e.Period, - } -} - -type FlockSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type AreaSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type ProductCategorySummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Code string `json:"code"` -} - -type FcrSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type LocationSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Address string `json:"address"` -} - -type KandangSummaryDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Status string `json:"status"` + Id uint `json:"id"` + Period int `json:"period"` } type ProjectFlockListDTO struct { ProjectFlockBaseDTO - Flock *FlockSummaryDTO `json:"flock,omitempty"` - Area *AreaSummaryDTO `json:"area,omitempty"` - ProductCategory *ProductCategorySummaryDTO `json:"product_category,omitempty"` - Fcr *FcrSummaryDTO `json:"fcr,omitempty"` - Location *LocationSummaryDTO `json:"location,omitempty"` - Kandangs []KandangSummaryDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + 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 FlockPeriodSummaryDTO struct { - Flock FlockSummaryDTO `json:"flock"` - NextPeriod int `json:"next_period"` +type FlockPeriodDTO struct { + Flock flockDTO.FlockBaseDTO `json:"flock"` + NextPeriod int `json:"next_period"` } func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { @@ -91,66 +50,56 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { createdUser = &mapped } - var flockSummary *FlockSummaryDTO + 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) + } + } + + var flockSummary *flockDTO.FlockBaseDTO if e.Flock.Id != 0 { - summary := ToFlockSummaryDTO(e.Flock) - flockSummary = &summary + mapped := flockDTO.ToFlockBaseDTO(e.Flock) + flockSummary = &mapped } - var areaSummary *AreaSummaryDTO + var areaSummary *areaDTO.AreaBaseDTO if e.Area.Id != 0 { - areaSummary = &AreaSummaryDTO{ - Id: e.Area.Id, - Name: e.Area.Name, - } + mapped := areaDTO.ToAreaBaseDTO(e.Area) + areaSummary = &mapped } - var categorySummary *ProductCategorySummaryDTO - if e.ProductCategory.Id != 0 { - categorySummary = &ProductCategorySummaryDTO{ - Id: e.ProductCategory.Id, - Name: e.ProductCategory.Name, - Code: e.ProductCategory.Code, - } - } - - var fcrSummary *FcrSummaryDTO + var fcrSummary *fcrDTO.FcrBaseDTO if e.Fcr.Id != 0 { - fcrSummary = &FcrSummaryDTO{ - Id: e.Fcr.Id, - Name: e.Fcr.Name, - } + mapped := fcrDTO.ToFcrBaseDTO(e.Fcr) + fcrSummary = &mapped } - var locationSummary *LocationSummaryDTO + var locationSummary *locationDTO.LocationBaseDTO if e.Location.Id != 0 { - locationSummary = &LocationSummaryDTO{ - Id: e.Location.Id, - Name: e.Location.Name, - Address: e.Location.Address, - } + mapped := locationDTO.ToLocationBaseDTO(e.Location) + locationSummary = &mapped } - kandangSummaries := make([]KandangSummaryDTO, len(e.Kandangs)) - for i, kandang := range e.Kandangs { - kandangSummaries[i] = KandangSummaryDTO{ - Id: kandang.Id, - Name: kandang.Name, - Status: kandang.Status, - } + latestApproval := defaultProjectFlockLatestApproval(e) + if e.LatestApproval != nil { + snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = snapshot } return ProjectFlockListDTO{ - ProjectFlockBaseDTO: ToProjectFlockBaseDTO(e), + ProjectFlockBaseDTO: createProjectFlockBaseDTO(e), Flock: flockSummary, Area: areaSummary, - ProductCategory: categorySummary, + Kandangs: kandangSummaries, + Category: e.Category, Fcr: fcrSummary, Location: locationSummary, - Kandangs: kandangSummaries, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, + Approval: latestApproval, } } @@ -168,15 +117,47 @@ func ToProjectFlockDetailDTO(e entity.ProjectFlock) ProjectFlockDetailDTO { } } -func ToFlockSummaryDTO(e entity.Flock) FlockSummaryDTO { - return FlockSummaryDTO{ +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 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 { + return ProjectFlockBaseDTO{ + Id: e.Id, + Period: e.Period, + } +} + +func ToFlockSummaryDTO(e entity.Flock) flockDTO.FlockBaseDTO { + return flockDTO.FlockBaseDTO{ Id: e.Id, Name: e.Name, } } -func ToFlockPeriodSummaryDTO(flock entity.Flock, next int) FlockPeriodSummaryDTO { - return FlockPeriodSummaryDTO{ +func ToFlockPeriodSummaryDTO(flock entity.Flock, next int) FlockPeriodDTO { + return FlockPeriodDTO{ Flock: ToFlockSummaryDTO(flock), NextPeriod: next, } diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go new file mode 100644 index 00000000..ff82fba9 --- /dev/null +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -0,0 +1,108 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + 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" +) + +// internal DTO used only for lookup response: project flock with kandangs carrying pivot ids +type KandangWithPivotDTO struct { + kandangDTO.KandangBaseDTO + ProjectFlockKandangId *uint `json:"project_flock_kandang_id,omitempty"` +} + +type ProjectFlockWithPivotDTO 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 []KandangWithPivotDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` +} + +type ProjectFlockKandangDTO struct { + Id uint `json:"id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` + ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` +} + +func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { + var kandang *kandangDTO.KandangBaseDTO + if e.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangBaseDTO(e.Kandang) + kandang = &mapped + } + + var pf *ProjectFlockWithPivotDTO + if e.ProjectFlock.Id != 0 { + // build project flock with kandangs that include pivot ids + pfLocal := ProjectFlockWithPivotDTO{ + ProjectFlockBaseDTO: ProjectFlockBaseDTO{ + Id: e.ProjectFlock.Id, + Period: e.ProjectFlock.Period, + }, + Category: e.ProjectFlock.Category, + } + + // fill related small summaries + if e.ProjectFlock.Flock.Id != 0 { + mapped := ToFlockSummaryDTO(e.ProjectFlock.Flock) + pfLocal.Flock = &mapped + } + if e.ProjectFlock.Area.Id != 0 { + mapped := areaDTO.ToAreaBaseDTO(e.ProjectFlock.Area) + pfLocal.Area = &mapped + } + if e.ProjectFlock.Fcr.Id != 0 { + mapped := fcrDTO.ToFcrBaseDTO(e.ProjectFlock.Fcr) + pfLocal.Fcr = &mapped + } + if e.ProjectFlock.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(e.ProjectFlock.Location) + pfLocal.Location = &mapped + } + if e.ProjectFlock.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.ProjectFlock.CreatedUser) + pfLocal.CreatedUser = &mapped + } + + // build pivot map + pivotMap := make(map[uint]uint) + for _, ph := range e.ProjectFlock.KandangHistory { + pivotMap[ph.KandangId] = ph.Id + } + + // populate kandangs with pivot ids + for _, k := range e.ProjectFlock.Kandangs { + kb := kandangDTO.ToKandangBaseDTO(k) + var pid *uint + if v, ok := pivotMap[k.Id]; ok { + vv := v + pid = &vv + } + pfLocal.Kandangs = append(pfLocal.Kandangs, KandangWithPivotDTO{ + KandangBaseDTO: kb, + ProjectFlockKandangId: pid, + }) + } + + pf = &pfLocal + } + + return ProjectFlockKandangDTO{ + Id: e.Id, + ProjectFlockId: e.ProjectFlockId, + KandangId: e.KandangId, + Kandang: kandang, + ProjectFlock: pf, + } +} diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 5b91ab13..994eb4a4 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -1,8 +1,12 @@ 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" @@ -10,6 +14,7 @@ import ( 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" @@ -24,7 +29,13 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, validate) + 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/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 9999e1a8..5c78f830 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -2,7 +2,6 @@ package repository import ( "context" - "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -10,8 +9,9 @@ import ( type ProjectFlockKandangRepository interface { GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) + GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error - MarkDetached(ctx context.Context, projectFlockID uint, kandangIDs []uint, detachedAt time.Time) 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 @@ -32,14 +32,13 @@ func (r *projectFlockKandangRepositoryImpl) CreateMany(ctx context.Context, reco return r.db.WithContext(ctx).Create(&records).Error } -func (r *projectFlockKandangRepositoryImpl) MarkDetached(ctx context.Context, projectFlockID uint, kandangIDs []uint, detachedAt time.Time) error { +func (r *projectFlockKandangRepositoryImpl) DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error { if len(kandangIDs) == 0 { return nil } return r.db.WithContext(ctx). - Model(&entity.ProjectFlockKandang{}). - Where("project_flock_id = ? AND kandang_id IN ? AND detached_at IS NULL", projectFlockID, kandangIDs). - Updates(map[string]any{"detached_at": detachedAt}).Error + Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs). + Delete(&entity.ProjectFlockKandang{}).Error } func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) { @@ -47,9 +46,14 @@ func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entit if err := r.db.WithContext(ctx). Preload("ProjectFlock"). Preload("ProjectFlock.Flock"). + Preload("ProjectFlock.Fcr"). + Preload("ProjectFlock.Area"). + Preload("ProjectFlock.Location"). + Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.Kandangs"). + Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). - Preload("CreatedUser"). - Order("project_flock_id ASC, assigned_at ASC"). + Order("project_flock_id ASC, created_at ASC"). Find(&records).Error; err != nil { return nil, err } @@ -69,10 +73,34 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint if err := r.db.WithContext(ctx). Preload("ProjectFlock"). Preload("ProjectFlock.Flock"). + Preload("ProjectFlock.Fcr"). + Preload("ProjectFlock.Area"). + Preload("ProjectFlock.Location"). + Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.Kandangs"). + Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). - Preload("CreatedUser"). First(record, id).Error; err != nil { return nil, err } return record, nil } + +func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { + record := new(entity.ProjectFlockKandang) + if err := r.db.WithContext(ctx). + Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). + Preload("ProjectFlock"). + Preload("ProjectFlock.Flock"). + Preload("ProjectFlock.Fcr"). + Preload("ProjectFlock.Area"). + Preload("ProjectFlock.Location"). + Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.Kandangs"). + Preload("ProjectFlock.KandangHistory"). + Preload("Kandang"). + First(record).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 index e5dbb48a..4c11d3a1 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -25,5 +25,7 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) route.Delete("/:id", ctrl.DeleteOne) + route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) + route.Post("/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 index 8af6e452..f9c7881e 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -4,17 +4,18 @@ import ( "context" "errors" "fmt" + "strconv" "strings" - "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + 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" - "gitlab.com/mbugroup/lti-api.git/internal/utils" + 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" @@ -28,16 +29,20 @@ type ProjectflockService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) DeleteOne(ctx *fiber.Ctx, id uint) error + GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) + Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) } type projectflockService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ProjectflockRepository - FlockRepo flockRepository.FlockRepository - KandangRepo kandangRepository.KandangRepository - PivotRepo repository.ProjectFlockKandangRepository + 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 { @@ -50,15 +55,18 @@ func NewProjectflockService( 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, + Log: utils.Log, + Validate: validate, + Repository: repo, + FlockRepo: flockRepo, + KandangRepo: kandangRepo, + PivotRepo: pivotRepo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } } @@ -67,7 +75,6 @@ func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser"). Preload("Flock"). Preload("Area"). - Preload("ProductCategory"). Preload("Fcr"). Preload("Location"). Preload("Kandangs") @@ -115,15 +122,13 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e 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 product_categories ON product_categories.id = project_flocks.product_category_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(product_categories.name) LIKE ? - OR LOWER(product_categories.code) LIKE ? + OR LOWER(project_flocks.category) LIKE ? OR LOWER(fcrs.name) LIKE ? OR LOWER(locations.name) LIKE ? OR LOWER(locations.address) LIKE ? @@ -146,7 +151,6 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e likeQuery, likeQuery, likeQuery, - likeQuery, ) } for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { @@ -159,6 +163,27 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e 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 } @@ -171,6 +196,23 @@ func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock 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 } @@ -179,16 +221,20 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } + cat := strings.ToUpper(req.Category) + if !utils.IsValidProjectFlockCategory(cat) { + 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 := common.EnsureRelations(c.Context(), - common.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, - common.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, - common.RelationCheck{Name: "Product category", ID: &req.ProductCategoryId, Exists: relationExistsChecker[entity.ProductCategory](s.Repository.DB())}, - common.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, - common.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB())}, + 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 } @@ -210,31 +256,48 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* } } - tx := s.Repository.DB().Begin() - if tx.Error != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") - } - - projectRepo := repository.NewProjectflockRepository(tx) - nextPeriod, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) - if err != nil { - tx.Rollback() - s.Log.Errorf("Failed to determine next period for flock %d: %+v", req.FlockId, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine next period") - } - createBody := &entity.ProjectFlock{ - FlockId: req.FlockId, - AreaId: req.AreaId, - ProductCategoryId: req.ProductCategoryId, - FcrId: req.FcrId, - LocationId: req.LocationId, - Period: nextPeriod, - CreatedBy: 1, + FlockId: req.FlockId, + AreaId: req.AreaId, + Category: cat, + FcrId: req.FcrId, + LocationId: req.LocationId, + CreatedBy: 1, } - if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { - tx.Rollback() + 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") } @@ -242,17 +305,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } - if err := s.attachKandangs(c.Context(), tx, createBody.Id, kandangIDs, createBody.CreatedBy); err != nil { - tx.Rollback() - s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", createBody.Id, err) - return nil, err - } - - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") - } - return s.GetOne(c, createBody.Id) } @@ -269,13 +321,14 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id s.Log.Errorf("Failed to fetch projectflock %d before update: %+v", id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - updateBody := make(map[string]any) - var relationChecks []common.RelationCheck + hasBodyChanges := false + var relationChecks []commonSvc.RelationCheck if req.FlockId != nil { updateBody["flock_id"] = *req.FlockId - relationChecks = append(relationChecks, common.RelationCheck{ + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "Flock", ID: req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB()), @@ -283,23 +336,25 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if req.AreaId != nil { updateBody["area_id"] = *req.AreaId - relationChecks = append(relationChecks, common.RelationCheck{ + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "Area", ID: req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB()), }) } - if req.ProductCategoryId != nil { - updateBody["product_category_id"] = *req.ProductCategoryId - relationChecks = append(relationChecks, common.RelationCheck{ - Name: "Product category", - ID: req.ProductCategoryId, - Exists: relationExistsChecker[entity.ProductCategory](s.Repository.DB()), - }) + if req.Category != nil { + cat := strings.ToUpper(*req.Category) + if !utils.IsValidProjectFlockCategory(cat) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") + } + + updateBody["category"] = cat } if req.FcrId != nil { updateBody["fcr_id"] = *req.FcrId - relationChecks = append(relationChecks, common.RelationCheck{ + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "FCR", ID: req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB()), @@ -307,24 +362,24 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if req.LocationId != nil { updateBody["location_id"] = *req.LocationId - relationChecks = append(relationChecks, common.RelationCheck{ + hasBodyChanges = true + relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "Location", ID: req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB()), }) } - if req.Period != nil { - updateBody["period"] = *req.Period - } if len(relationChecks) > 0 { - if err := common.EnsureRelations(c.Context(), relationChecks...); err != nil { + 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") } @@ -346,72 +401,205 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } } - tx := s.Repository.DB().Begin() - if tx.Error != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + hasChanges := hasBodyChanges || hasKandangChanges + if !hasChanges { + return s.GetOne(c, id) } - projectRepo := repository.NewProjectflockRepository(tx) - if len(updateBody) > 0 { - if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { - tx.Rollback() - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + 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 } - s.Log.Errorf("Failed to update projectflock: %+v", err) - return nil, err - } - } - - if req.KandangIds != nil { - existingIDs := make(map[uint]struct{}, len(existing.Kandangs)) - for _, k := range existing.Kandangs { - existingIDs[k.Id] = struct{}{} - } - newSet := make(map[uint]struct{}, len(newKandangIDs)) - for _, id := range newKandangIDs { - newSet[id] = struct{}{} - } - - var toDetach []uint - for id := range existingIDs { - if _, ok := newSet[id]; !ok { - toDetach = append(toDetach, id) + } else { + if _, err := projectRepo.GetByID(c.Context(), id, nil); err != nil { + return err } } - var toAttach []uint - for id := range newSet { - if _, ok := existingIDs[id]; !ok { - toAttach = append(toAttach, id) + 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 len(toDetach) > 0 { - if err := s.detachKandangs(c.Context(), tx, id, toDetach, false); err != nil { - tx.Rollback() - s.Log.Errorf("Failed to detach kandangs from projectflock %d: %+v", id, err) - return nil, 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 + } + } } } - if len(toAttach) > 0 { - if err := s.attachKandangs(c.Context(), tx, id, toAttach, existing.CreatedBy); err != nil { - tx.Rollback() - s.Log.Errorf("Failed to attach kandangs to projectflock %d: %+v", id, err) - return nil, err - } - } - } + return nil + }) - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + 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, req *validation.Approve) ([]entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + 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") + } + + approvableIDs := uniqueUintSlice(req.ApprovableIds) + if len(approvableIDs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + 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)) + kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) + projectRepoTx := repository.NewProjectflockRepository(dbTransaction) + + for _, approvableID := range approvableIDs { + if _, err := projectRepoTx.GetByID(c.Context(), approvableID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Projectflock %d not found", approvableID)) + } + return err + } + + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + approvableID, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + + switch action { + case entity.ApprovalActionApproved: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + approvableID, + utils.KandangStatusActive, + ); err != nil { + return err + } + case entity.ApprovalActionRejected: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + approvableID, + utils.KandangStatusNonActive, + ); 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 record approval for projectflocks %+v: %+v", approvableIDs, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") + } + + updated := make([]entity.ProjectFlock, 0, len(approvableIDs)) + for _, approvableID := range approvableIDs { + project, err := s.GetOne(c, approvableID) + if err != nil { + return nil, err + } + updated = append(updated, *project) + } + + return updated, nil +} + 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) { @@ -422,38 +610,86 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - tx := s.Repository.DB().Begin() - if tx.Error != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") - } - - if len(existing.Kandangs) > 0 { - ids := make([]uint, len(existing.Kandangs)) - for i, k := range existing.Kandangs { - ids[i] = k.Id + 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 := s.detachKandangs(c.Context(), tx, id, ids, true); err != nil { - tx.Rollback() - s.Log.Errorf("Failed to detach kandangs before deleting projectflock %d: %+v", id, 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 } - } - if err := repository.NewProjectflockRepository(tx).DeleteOne(c.Context(), id); err != nil { - tx.Rollback() - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr } - s.Log.Errorf("Failed to delete projectflock: %+v", err) + s.Log.Errorf("Failed to delete projectflock %d: %+v", id, err) return err } - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + return nil +} + +func (s projectflockService) GetProjectFlockKandang(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, error) { + // keep for backward compatibility; delegate to new consolidated method + return s.GetProjectFlockKandangByParams(ctx, fmt.Sprintf("%d", id), "", "") +} + +func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { + + pfk, err := s.PivotRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + return nil, err + } + return pfk, nil +} + +func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) { + idStr = strings.TrimSpace(idStr) + projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) + kandangIdStr = strings.TrimSpace(kandangIdStr) + + if idStr != "" { + id, err := strconv.Atoi(idStr) + if err != nil || id <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + pfk, err := s.PivotRepo.GetByID(ctx.Context(), uint(id)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + return nil, err + } + return pfk, nil } - return nil + if projectFlockIdStr == "" || kandangIdStr == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Missing lookup parameters") + } + pfid, err := strconv.Atoi(projectFlockIdStr) + if err != nil || pfid <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + kid, err := strconv.Atoi(kandangIdStr) + if err != nil || kid <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) } func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { @@ -534,24 +770,26 @@ func (s projectflockService) buildOrderExpressions(sortBy, sortOrder string) []s } } -func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, projectFlockID uint, kandangIDs []uint, createdBy uint) error { +func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint) error { if len(kandangIDs) == 0 { return nil } - if err := tx.Model(&entity.Kandang{}). + if err := dbTransaction.Model(&entity.Kandang{}). Where("id IN ?", kandangIDs). - Updates(map[string]any{"project_flock_id": projectFlockID}).Error; err != nil { + 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(tx) + pivotRepo := s.pivotRepoWithTx(dbTransaction) records := make([]*entity.ProjectFlockKandang, len(kandangIDs)) for i, id := range kandangIDs { records[i] = &entity.ProjectFlockKandang{ ProjectFlockId: projectFlockID, KandangId: id, - CreatedBy: createdBy, } } if err := pivotRepo.CreateMany(ctx, records); err != nil { @@ -560,7 +798,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, tx *gorm.DB, pr return nil } -func (s projectflockService) detachKandangs(ctx context.Context, tx *gorm.DB, projectFlockID uint, kandangIDs []uint, resetStatus bool) error { +func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint, resetStatus bool) error { if len(kandangIDs) == 0 { return nil } @@ -570,21 +808,21 @@ func (s projectflockService) detachKandangs(ctx context.Context, tx *gorm.DB, pr updates["status"] = string(utils.KandangStatusNonActive) } - if err := tx.Model(&entity.Kandang{}). + 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(tx).MarkDetached(ctx, projectFlockID, kandangIDs, time.Now()); err != nil { + 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(tx *gorm.DB) repository.ProjectFlockKandangRepository { +func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { if s.PivotRepo == nil { - return repository.NewProjectFlockKandangRepository(tx) + return repository.NewProjectFlockKandangRepository(dbTransaction) } - return s.PivotRepo.WithTx(tx) + 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 index 0d8d3a80..f853c883 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,32 +1,37 @@ package validation type Create struct { - FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"` - AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` - ProductCategoryId uint `json:"product_category_id" validate:"required_strict,number,gt=0"` - FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` - LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` + 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"` + FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` } type Update struct { - FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"` - AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` - ProductCategoryId *uint `json:"product_category_id,omitempty" validate:"omitempty,number,gt=0"` - FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` - LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` - Period *int `json:"period,omitempty" validate:"omitempty,number,gt=0"` - KandangIds []uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,gt=0"` + 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"` + 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"` + 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"` + 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"` + ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} diff --git a/internal/modules/shared/repositories/audit-logs.repository.go b/internal/modules/shared/repositories/audit-logs.repository.go deleted file mode 100644 index b247f3f2..00000000 --- a/internal/modules/shared/repositories/audit-logs.repository.go +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 9d3ae632..00000000 --- a/internal/modules/shared/repositories/stock-availabilites.repository.go +++ /dev/null @@ -1,21 +0,0 @@ -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/route/route.go b/internal/route/route.go index b1cd62a4..60f0fe26 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -13,6 +13,7 @@ import ( 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 ) @@ -28,6 +29,7 @@ func Routes(app *fiber.App, db *gorm.DB) { 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 dbc06660..bdbc53b6 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 @@ -59,7 +63,6 @@ var allFlagTypes = func() map[FlagType]struct{} { return m }() - func AllFlagTypes() map[FlagType]struct{} { return allFlagTypes } @@ -76,8 +79,6 @@ const ( WarehouseTypeKandang WarehouseType = "KANDANG" ) - - // ------------------------------------------------------------------- // WarehouseType // ------------------------------------------------------------------- @@ -100,19 +101,45 @@ const ( SupplierCategorySapronak SupplierCategory = "SAPRONAK" ) - - // ------------------------------------------------------------------- -// Kandang Status +// Kandang Status // ------------------------------------------------------------------- type KandangStatus string const ( - KandangStatusNonActive KandangStatus = "NON_ACTIVE" - KandangStatusPengajuan KandangStatus = "PENGAJUAN" - KandangStatusActive KandangStatus = "ACTIVE" + 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 // ------------------------------------------------------------------- @@ -223,6 +250,14 @@ func IsValidCustomerSupplierType(v string) bool { return false } +func IsValidProjectFlockCategory(v string) bool { + switch ProjectFlockCategory(v) { + case ProjectFlockCategoryGrowing, ProjectFlockCategoryLaying: + return true + } + return false +} + func IsValidSupplierCategory(v string) bool { switch SupplierCategory(v) { case SupplierCategoryBOP, SupplierCategorySapronak: diff --git a/test/integration/master_data/kandang_test.go b/test/integration/master_data/kandang_test.go index 580196d4..6f7c5ce7 100644 --- a/test/integration/master_data/kandang_test.go +++ b/test/integration/master_data/kandang_test.go @@ -8,6 +8,7 @@ import ( "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) { @@ -51,20 +52,19 @@ func TestKandangIntegration(t *testing.T) { }) t.Run("cannot assign project floc with existing active kandang", func(t *testing.T) { - categoryID := createProductCategory(t, app, "DOC Category", "DOC1") fcrID := createFcr(t, app, "FCR For Floc", []map[string]any{ {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, }) flocID := createFlock(t, app, "Floc Test") projectFloc := entities.ProjectFlock{ - FlockId: flocID, - AreaId: areaID, - ProductCategoryId: categoryID, - FcrId: fcrID, - LocationId: locationID, - Period: 1, - CreatedBy: 1, + 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) diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go index c5e0442c..60bb2d90 100644 --- a/test/integration/master_data/project_flock_test.go +++ b/test/integration/master_data/project_flock_test.go @@ -19,19 +19,18 @@ func TestProjectFlockSummary(t *testing.T) { areaID := createArea(t, app, "Area Project") locationID := createLocation(t, app, "Location Project", "Address", areaID) flockID := createFlock(t, app, "Flock Summary") - categoryID := createProductCategory(t, app, "DOC Summary", "DOCS") fcrID := createFcr(t, app, "FCR Summary", []map[string]any{ {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, }) kandangID := createKandang(t, app, "Kandang Summary", locationID, 1) createPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{kandangID}, + "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 { @@ -40,9 +39,10 @@ func TestProjectFlockSummary(t *testing.T) { var createResp struct { Data struct { - Id uint `json:"id"` - Period int `json:"period"` - Flock 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"` @@ -50,11 +50,6 @@ func TestProjectFlockSummary(t *testing.T) { Id uint `json:"id"` Name string `json:"name"` } `json:"area"` - ProductCategory struct { - Id uint `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - } `json:"product_category"` Fcr struct { Id uint `json:"id"` Name string `json:"name"` @@ -86,19 +81,27 @@ func TestProjectFlockSummary(t *testing.T) { 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 == "" { - t.Fatalf("expected kandang status to be present, got %+v", createResp.Data.Kandangs[0]) + 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) @@ -110,18 +113,15 @@ func TestProjectFlockSummary(t *testing.T) { if firstPivotRecord.KandangId != kandangID { t.Fatalf("expected pivot kandang id %d, got %d", kandangID, firstPivotRecord.KandangId) } - if firstPivotRecord.DetachedAt != nil { - t.Fatalf("expected pivot DetachedAt to be nil for active assignment, got %v", firstPivotRecord.DetachedAt) - } secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) secondPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{secondKandangID}, + "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 { @@ -129,8 +129,9 @@ func TestProjectFlockSummary(t *testing.T) { } var createRespSecond struct { Data struct { - Id uint `json:"id"` - Period int `json:"period"` + Id uint `json:"id"` + Period int `json:"period"` + Category string `json:"category"` } `json:"data"` } if err := json.Unmarshal(body, &createRespSecond); err != nil { @@ -139,6 +140,9 @@ func TestProjectFlockSummary(t *testing.T) { 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 { @@ -151,8 +155,10 @@ func TestProjectFlockSummary(t *testing.T) { if secondPivotRecord.KandangId != secondKandangID { t.Fatalf("expected second pivot kandang id %d, got %d", secondKandangID, secondPivotRecord.KandangId) } - if secondPivotRecord.DetachedAt != nil { - t.Fatalf("expected second pivot DetachedAt to be nil, got %v", secondPivotRecord.DetachedAt) + + 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) @@ -186,15 +192,14 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected kandang status to revert to NON_ACTIVE, got %s", firstKandang.Status) } - var firstPivot entities.ProjectFlockKandang - if err := db.First(&firstPivot, firstPivotRecord.Id).Error; err != nil { - t.Fatalf("failed to reload first pivot record: %v", err) + 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 firstPivot.DetachedAt == nil { - t.Fatalf("expected first pivot DetachedAt to be set after delete") - } - if firstPivot.ProjectFlockId != createResp.Data.Id { - t.Fatalf("expected first pivot project_flock_id %d, got %d", createResp.Data.Id, firstPivot.ProjectFlockId) + 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) @@ -202,7 +207,7 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body)) } - secondKandang := fetchKandang(t, db, secondKandangID) + 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) } @@ -210,15 +215,14 @@ func TestProjectFlockSummary(t *testing.T) { t.Fatalf("expected second kandang status to revert to NON_ACTIVE, got %s", secondKandang.Status) } - var secondPivot entities.ProjectFlockKandang - if err := db.First(&secondPivot, secondPivotRecord.Id).Error; err != nil { - t.Fatalf("failed to reload second pivot record: %v", err) + 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 secondPivot.DetachedAt == nil { - t.Fatalf("expected second pivot DetachedAt to be set after delete") - } - if secondPivot.ProjectFlockId != createRespSecond.Data.Id { - t.Fatalf("expected second pivot project_flock_id %d, got %d", createRespSecond.Data.Id, secondPivot.ProjectFlockId) + 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) @@ -245,19 +249,18 @@ func TestProjectFlockSearchByRelatedFields(t *testing.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") - categoryID := createProductCategory(t, app, "Category Search Target", "CATGT") 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, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{kandangID}, + "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) @@ -277,8 +280,8 @@ func TestProjectFlockSearchByRelatedFields(t *testing.T) { searchTerms := []string{ "Flock Search Target", "Area Search Target", - "Category Search Target", - "CATGT", + string(utils.ProjectFlockCategoryGrowing), + "growing", "FCR Search Target", "Kandang Search Target", "Location Search Target", @@ -329,7 +332,6 @@ func TestProjectFlockSorting(t *testing.T) { flockOne := createFlock(t, app, "Flock Sort One") flockTwo := createFlock(t, app, "Flock Sort Two") - categoryID := createProductCategory(t, app, "Category Sort", "CSORT") fcrID := createFcr(t, app, "FCR Sort", []map[string]any{ {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, }) @@ -339,12 +341,12 @@ func TestProjectFlockSorting(t *testing.T) { kandangThree := createKandang(t, app, "Kandang Sort Three", locationB, 1) projectOnePayload := map[string]any{ - "flock_id": flockOne, - "area_id": areaA, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationA, - "kandang_ids": []uint{kandangOne}, + "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 { @@ -353,12 +355,12 @@ func TestProjectFlockSorting(t *testing.T) { projectOneID := parseProjectFlockID(t, body) projectTwoPayload := map[string]any{ - "flock_id": flockTwo, - "area_id": areaB, - "product_category_id": categoryID, - "fcr_id": fcrID, - "location_id": locationB, - "kandang_ids": []uint{kandangTwo, kandangThree}, + "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 { 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}}