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
@@ -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