fix: resolve dashboard OpenAPI integration issues

- FCRs & Transfer to Laying: add ExampleResponse field to routeMeta and
  inject example payloads into OpenAPI 200 responses for list and detail
  endpoints so dashboard consumers have concrete response shapes to work with

- Chick In: enable GET /api/production/chickins/ list endpoint (was
  commented out); add P_ChickinsGetAll permission constant and wire it
  into the route; add OpenAPI spec entry with query params and example

- Recording GET all: fix N+1 query bottleneck (2-3s response time) by
  pre-fetching approved transfer maps per PFK ID in two batch queries
  before the per-recording loop; add evaluatePopulationMutationStateFromCaches
  that uses the pre-fetched maps and caches hasAnyRecordingOnTransferTargets
  results by transfer ID — reducing per-page query count from ~20-40 to ~10-12

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Adnan Zahir
2026-05-02 10:57:45 +07:00
parent c804c59f05
commit 3768892a17
6 changed files with 370 additions and 38 deletions
+1
View File
@@ -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"
@@ -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")
@@ -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)
@@ -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
@@ -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
}
+106 -3
View File
@@ -51,6 +51,7 @@ type routeMeta struct {
Security securityMode
ListStyle bool
QueryParams []parameterMeta
ExampleResponse any
Exclude bool
}
@@ -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},