diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index d2381b78..a29ccd91 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -207,6 +207,7 @@ const ( P_Finances_Transaction_DeleteOne = "lti.finance.transactions.delete" ) const ( + P_ChickinsGetAll = "lti.production.chickins.list" P_ChickinsCreateOne = "lti.production.chickins.create" P_ChickinsGetOne = "lti.production.chickins.detail" P_ChickinsApproval = "lti.production.chickins.approve" diff --git a/internal/modules/production/chickins/controllers/chickin.controller.go b/internal/modules/production/chickins/controllers/chickin.controller.go index 0d9c67e0..f4425206 100644 --- a/internal/modules/production/chickins/controllers/chickin.controller.go +++ b/internal/modules/production/chickins/controllers/chickin.controller.go @@ -1,6 +1,7 @@ package controller import ( + "math" "strconv" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto" @@ -21,32 +22,32 @@ func NewChickinController(chickinService service.ChickinService) *ChickinControl } } -// func (u *ChickinController) GetAll(c *fiber.Ctx) error { -// query := &validation.Query{ -// Page: c.QueryInt("page", 1), -// Limit: c.QueryInt("limit", 10), -// ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), -// } +func (u *ChickinController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), + } -// result, totalResults, err := u.ChickinService.GetAll(c, query) -// if err != nil { -// return err -// } + result, totalResults, err := u.ChickinService.GetAll(c, query) + if err != nil { + return err + } -// return c.Status(fiber.StatusOK). -// JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{ -// Code: fiber.StatusOK, -// Status: "success", -// Message: "Get all chickins successfully", -// Meta: response.Meta{ -// Page: query.Page, -// Limit: query.Limit, -// TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), -// TotalResults: totalResults, -// }, -// Data: dto.ToChickinListDTOs(result), -// }) -// } + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all chickins successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToChickinListDTOs(result), + }) +} // func (u *ChickinController) GetOne(c *fiber.Ctx) error { // param := c.Params("id") diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go index 4b49969a..4243a602 100644 --- a/internal/modules/production/chickins/route.go +++ b/internal/modules/production/chickins/route.go @@ -15,8 +15,8 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService route := v1.Group("/chickins") route.Use(m.Auth(u)) - // route.Get("/", ctrl.GetAll) - route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) + route.Get("/", m.RequirePermissions(m.P_ChickinsGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) // route.Patch("/:id", ctrl.UpdateOne) route.Delete("/:id", ctrl.DeleteOne) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 4efa6b06..5d24cf76 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -173,6 +173,37 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } + // Pre-fetch transfer maps by category to avoid N+1 per-recording queries. + growingPFKIDs := make([]uint, 0, len(pfkIDs)) + layingPFKIDs := make([]uint, 0, len(pfkIDs)) + seenCat := make(map[uint]bool, len(pfkIDs)) + for i := range recordings { + pfkID := recordings[i].ProjectFlockKandangId + if pfkID == 0 || seenCat[pfkID] { + continue + } + seenCat[pfkID] = true + cat := "" + if recordings[i].ProjectFlockKandang != nil && recordings[i].ProjectFlockKandang.ProjectFlock.Id != 0 { + cat = strings.ToUpper(strings.TrimSpace(recordings[i].ProjectFlockKandang.ProjectFlock.Category)) + } + switch cat { + case string(utils.ProjectFlockCategoryGrowing): + growingPFKIDs = append(growingPFKIDs, pfkID) + case string(utils.ProjectFlockCategoryLaying): + layingPFKIDs = append(layingPFKIDs, pfkID) + } + } + sourceTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandangs(c.Context(), growingPFKIDs) + if err != nil { + return nil, 0, err + } + targetTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandangs(c.Context(), layingPFKIDs) + if err != nil { + return nil, 0, err + } + hasTargetRecordingCache := make(map[uint]bool) + cutOverChickinAvailability := make(map[uint]bool) for i := range recordings { if recordings[i].ProjectFlockKandangId != 0 && !recordings[i].RecordDatetime.IsZero() { @@ -192,7 +223,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) recordings[i].DepletionRate = &rate - populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i]) + populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationStateFromCaches(c.Context(), &recordings[i], sourceTransferByPFK, targetTransferByPFK, hasTargetRecordingCache) if stateErr != nil { return nil, 0, stateErr } @@ -1308,6 +1339,82 @@ func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil } +// evaluatePopulationMutationStateFromCaches is identical to evaluatePopulationMutationState +// but uses pre-fetched transfer maps to avoid N+1 queries in list endpoints. +func (s *recordingService) evaluatePopulationMutationStateFromCaches( + ctx context.Context, + recording *entity.Recording, + sourceTransferByPFK map[uint]*entity.LayingTransfer, + targetTransferByPFK map[uint]*entity.LayingTransfer, + hasTargetRecordingCache map[uint]bool, +) (bool, bool, bool, bool, *entity.LayingTransfer, time.Time, error) { + if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil { + return true, false, false, false, nil, time.Time{}, nil + } + + category, err := s.resolveRecordingCategory(ctx, recording) + if err != nil { + s.Log.Errorf("Failed to resolve recording category for population mutation check (recording=%d): %+v", recording.Id, err) + return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") + } + + var transfer *entity.LayingTransfer + switch category { + case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): + transfer = sourceTransferByPFK[recording.ProjectFlockKandangId] + case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): + transfer = targetTransferByPFK[recording.ProjectFlockKandangId] + default: + return true, false, false, false, nil, time.Time{}, nil + } + + if transfer == nil { + return true, false, false, false, nil, time.Time{}, nil + } + + transferDate := transferPhysicalMoveDate(transfer) + if transferDate.IsZero() { + return true, false, false, false, transfer, transferDate, nil + } + + transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() + recordDate := normalizeDateOnlyUTC(recording.RecordDatetime) + _, economicCutoffDate := transferRecordingWindow(transfer) + isTransition := !recordDate.Before(transferDate) && recordDate.Before(economicCutoffDate) + isLaying := !recordDate.Before(economicCutoffDate) + + populationCanChange := true + if category == strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) { + populationCanChange = !(transferExecuted && !recordDate.Before(transferDate)) + + if transferExecuted && !recordDate.Before(transferDate) { + var hasTargetLayingRecording bool + if cached, ok := hasTargetRecordingCache[transfer.Id]; ok { + hasTargetLayingRecording = cached + } else { + hasTargetLayingRecording, err = s.hasAnyRecordingOnTransferTargets(ctx, transfer) + if err != nil { + s.Log.Errorf("Failed to resolve target laying recording state for transfer %d: %+v", transfer.Id, err) + return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi status transisi recording") + } + hasTargetRecordingCache[transfer.Id] = hasTargetLayingRecording + } + if hasTargetLayingRecording { + isTransition = false + isLaying = true + } else { + today := normalizeDateOnlyUTC(time.Now().UTC()) + if !today.Before(economicCutoffDate) { + isTransition = true + isLaying = false + } + } + } + } + + return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil +} + func (s *recordingService) hasAnyRecordingOnTransferTargets(ctx context.Context, transfer *entity.LayingTransfer) (bool, error) { if transfer == nil || transfer.Id == 0 { return false, nil diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index 8c21f176..1b9dfe9f 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -17,6 +17,8 @@ type TransferLayingRepository interface { IdExists(ctx context.Context, id uint) (bool, error) GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error) GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) + GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) + GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) // Tambah method baru untuk query dengan filter lengkap GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) @@ -242,3 +244,121 @@ func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandang(ctx cont } return &transfer, nil } + +type pfkTransferIDRow struct { + SourcePFKID uint `gorm:"column:source_pfk_id"` + TransferID uint `gorm:"column:transfer_id"` +} + +func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) { + result := make(map[uint]*entity.LayingTransfer) + if len(pfkIDs) == 0 { + return result, nil + } + + var rows []pfkTransferIDRow + err := r.db.WithContext(ctx).Raw(` + SELECT DISTINCT ON (source_pfk_id) source_pfk_id, transfer_id + FROM ( + SELECT id AS transfer_id, source_project_flock_kandang_id AS source_pfk_id + FROM laying_transfers + WHERE source_project_flock_kandang_id IN ? + AND deleted_at IS NULL + AND ( + SELECT a.action FROM approvals a + WHERE a.approvable_type = ? AND a.approvable_id = id + ORDER BY a.id DESC LIMIT 1 + ) = ? + UNION ALL + SELECT lts.laying_transfer_id AS transfer_id, lts.source_project_flock_kandang_id AS source_pfk_id + FROM laying_transfer_sources lts + JOIN laying_transfers t ON t.id = lts.laying_transfer_id AND t.deleted_at IS NULL + WHERE lts.source_project_flock_kandang_id IN ? + AND lts.deleted_at IS NULL + AND ( + SELECT a.action FROM approvals a + WHERE a.approvable_type = ? AND a.approvable_id = t.id + ORDER BY a.id DESC LIMIT 1 + ) = ? + ) combined + ORDER BY source_pfk_id, transfer_id DESC + `, + pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved), + pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved), + ).Scan(&rows).Error + if err != nil { + return nil, err + } + if len(rows) == 0 { + return result, nil + } + + transferIDs := make([]uint, 0, len(rows)) + pfkByTransfer := make(map[uint]uint, len(rows)) + for _, row := range rows { + transferIDs = append(transferIDs, row.TransferID) + pfkByTransfer[row.TransferID] = row.SourcePFKID + } + + var transfers []entity.LayingTransfer + if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Find(&transfers).Error; err != nil { + return nil, err + } + for i := range transfers { + if pfkID := pfkByTransfer[transfers[i].Id]; pfkID != 0 { + result[pfkID] = &transfers[i] + } + } + return result, nil +} + +func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) { + result := make(map[uint]*entity.LayingTransfer) + if len(pfkIDs) == 0 { + return result, nil + } + + var rows []pfkTransferIDRow + err := r.db.WithContext(ctx).Raw(` + SELECT DISTINCT ON (source_pfk_id) source_pfk_id, transfer_id + FROM ( + SELECT ltt.laying_transfer_id AS transfer_id, ltt.target_project_flock_kandang_id AS source_pfk_id + FROM laying_transfer_targets ltt + JOIN laying_transfers t ON t.id = ltt.laying_transfer_id AND t.deleted_at IS NULL + WHERE ltt.target_project_flock_kandang_id IN ? + AND ltt.deleted_at IS NULL + AND ( + SELECT a.action FROM approvals a + WHERE a.approvable_type = ? AND a.approvable_id = t.id + ORDER BY a.id DESC LIMIT 1 + ) = ? + ) combined + ORDER BY source_pfk_id, transfer_id DESC + `, + pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved), + ).Scan(&rows).Error + if err != nil { + return nil, err + } + if len(rows) == 0 { + return result, nil + } + + transferIDs := make([]uint, 0, len(rows)) + pfkByTransfer := make(map[uint]uint, len(rows)) + for _, row := range rows { + transferIDs = append(transferIDs, row.TransferID) + pfkByTransfer[row.TransferID] = row.SourcePFKID + } + + var transfers []entity.LayingTransfer + if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Find(&transfers).Error; err != nil { + return nil, err + } + for i := range transfers { + if pfkID := pfkByTransfer[transfers[i].Id]; pfkID != 0 { + result[pfkID] = &transfers[i] + } + } + return result, nil +} diff --git a/internal/readapi/readapi.go b/internal/readapi/readapi.go index 59fe1329..1db6ba92 100644 --- a/internal/readapi/readapi.go +++ b/internal/readapi/readapi.go @@ -44,14 +44,15 @@ type parameterMeta struct { } type routeMeta struct { - Group string - Tag string - Summary string - Description string - Security securityMode - ListStyle bool - QueryParams []parameterMeta - Exclude bool + Group string + Tag string + Summary string + Description string + Security securityMode + ListStyle bool + QueryParams []parameterMeta + ExampleResponse any + Exclude bool } func RegisterRoutes(router fiber.Router) { @@ -187,6 +188,13 @@ func buildOpenAPIDocument(routes []normalizedRoute) map[string]any { } openAPIPath := toOpenAPIPath(route.Path) + responseContent := map[string]any{ + "schema": successSchema(meta), + } + if meta.ExampleResponse != nil { + responseContent["example"] = meta.ExampleResponse + } + operation := map[string]any{ "summary": meta.Summary, "description": meta.Description, @@ -195,9 +203,7 @@ func buildOpenAPIDocument(routes []normalizedRoute) map[string]any { "200": map[string]any{ "description": "Successful response", "content": map[string]any{ - "application/json": map[string]any{ - "schema": successSchema(meta), - }, + "application/json": responseContent, }, }, "401": map[string]any{ @@ -777,6 +783,31 @@ func describeRoute(route normalizedRoute) routeMeta { {Name: "limit", In: "query", Description: "Page size.", Example: 10}, {Name: "search", In: "query", Description: "Search keyword.", Example: "fcr"}, } + meta.ExampleResponse = map[string]any{ + "code": 200, "status": "success", "message": "Get all fcrs successfully", + "meta": map[string]any{"page": 1, "limit": 10, "total_pages": 1, "total_results": 1}, + "data": []map[string]any{ + { + "id": 1, "name": "FCR Broiler Standard", + "created_user": map[string]any{"id": 1, "name": "Admin"}, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", + }, + }, + } + case "/api/master-data/fcrs/:id": + meta.ExampleResponse = map[string]any{ + "code": 200, "status": "success", "message": "Get fcr successfully", + "data": map[string]any{ + "id": 1, "name": "FCR Broiler Standard", + "created_user": map[string]any{"id": 1, "name": "Admin"}, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", + "fcr_standards": []map[string]any{ + {"id": 1, "weight": 0.5, "fcr_number": 1.2, "mortality": 0.5}, + {"id": 2, "weight": 1.0, "fcr_number": 1.35, "mortality": 0.3}, + {"id": 3, "weight": 1.5, "fcr_number": 1.5, "mortality": 0.25}, + }, + }, + } case "/api/master-data/flocks": meta.QueryParams = []parameterMeta{ {Name: "page", In: "query", Description: "Page number.", Example: 1}, @@ -926,6 +957,31 @@ func describeRoute(route normalizedRoute) routeMeta { {Name: "project_flock_kandang_id", In: "query", Description: "Project flock kandang id.", Required: true, Example: 1, PostmanValue: "{{project_flock_kandang_id}}"}, {Name: "record_date", In: "query", Description: "Recording date (YYYY-MM-DD).", Required: true, Example: "2026-01-01"}, } + case "/api/production/chickins": + meta.QueryParams = []parameterMeta{ + {Name: "page", In: "query", Description: "Page number.", Example: 1}, + {Name: "limit", In: "query", Description: "Page size.", Example: 10}, + {Name: "project_flock_kandang_id", In: "query", Description: "Project flock kandang id filter.", Example: 1, PostmanValue: "{{project_flock_kandang_id}}"}, + } + meta.ExampleResponse = map[string]any{ + "code": 200, "status": "success", "message": "Get all chickins successfully", + "meta": map[string]any{"page": 1, "limit": 10, "total_pages": 1, "total_results": 1}, + "data": []map[string]any{ + { + "id": 1, "project_flock_kandang_id": 1, + "chick_in_date": "2026-01-01T00:00:00Z", + "product_warehouse_id": 1, + "product_warehouse": map[string]any{ + "id": 1, + "product": map[string]any{"id": 1, "name": "DOC Broiler"}, + "warehouse": map[string]any{"id": 1, "name": "Gudang DOC"}, + }, + "usage_qty": 10000.0, "pending_usage_qty": 0.0, "notes": "", + "created_user": map[string]any{"id": 1, "name": "Admin"}, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", + }, + }, + } case "/api/production/transfer_layings": meta.QueryParams = []parameterMeta{ {Name: "page", In: "query", Description: "Page number.", Example: 1}, @@ -937,6 +993,53 @@ func describeRoute(route normalizedRoute) routeMeta { {Name: "flock_destination", In: "query", Description: "Comma separated destination flock ids.", Example: "3,4"}, {Name: "status", In: "query", Description: "Comma separated status values.", Example: "DRAFT,APPROVED"}, } + meta.ExampleResponse = map[string]any{ + "code": 200, "status": "success", "message": "Get all transferLayings successfully", + "meta": map[string]any{"page": 1, "limit": 10, "total_pages": 1, "total_results": 1}, + "data": []map[string]any{ + { + "id": 1, "transfer_number": "TL-00001", + "transfer_date": "2026-01-15T00:00:00Z", + "economic_cutoff_date": "2026-01-20T00:00:00Z", + "effective_move_date": "2026-01-18T00:00:00Z", + "executed_at": nil, "notes": "", + "from_project_flock": map[string]any{"id": 1, "flock_name": "Flock A Period 1"}, + "to_project_flock": map[string]any{"id": 2, "flock_name": "Flock B Period 1"}, + "created_by": 1, + "created_user": map[string]any{"id": 1, "name": "Admin"}, + "created_at": "2026-01-15T00:00:00Z", + "approval": map[string]any{"step_number": 1, "step_name": "Pengajuan", "action": nil}, + }, + }, + } + case "/api/production/transfer_layings/:id": + meta.ExampleResponse = map[string]any{ + "code": 200, "status": "success", "message": "Get transferLaying successfully", + "data": map[string]any{ + "id": 1, "transfer_number": "TL-00001", + "transfer_date": "2026-01-15T00:00:00Z", + "economic_cutoff_date": "2026-01-20T00:00:00Z", + "effective_move_date": "2026-01-18T00:00:00Z", + "executed_at": nil, "notes": "", + "from_project_flock": map[string]any{"id": 1, "flock_name": "Flock A Period 1"}, + "to_project_flock": map[string]any{"id": 2, "flock_name": "Flock B Period 1"}, + "created_by": 1, "created_user": map[string]any{"id": 1, "name": "Admin"}, + "created_at": "2026-01-15T00:00:00Z", + "approval": map[string]any{"step_number": 1, "step_name": "Pengajuan", "action": nil}, + "sources": []map[string]any{ + { + "source_project_flock_kandang": map[string]any{"id": 1, "kandang_id": 1, "project_flock_id": 1, "kandang": map[string]any{"id": 1, "name": "Kandang A"}}, + "qty": 5000.0, "note": "", + }, + }, + "targets": []map[string]any{ + { + "target_project_flock_kandang": map[string]any{"id": 2, "kandang_id": 2, "project_flock_id": 2, "kandang": map[string]any{"id": 2, "name": "Kandang B"}}, + "qty": 5000.0, "note": "", + }, + }, + }, + } case "/api/production/uniformities": meta.QueryParams = []parameterMeta{ {Name: "page", In: "query", Description: "Page number.", Example: 1},