package service import ( "context" "fmt" "math" "sort" "strconv" "strings" "time" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" ) type RepportService interface { GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) } type repportService struct { Log *logrus.Logger Validate *validator.Validate ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository PurchaseRepo purchaseRepo.PurchaseRepository ChickinRepo chickinRepo.ProjectChickinRepository RecordingRepo recordingRepo.RecordingRepository ApprovalSvc approvalService.ApprovalService PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository HppPerKandangRepo repportRepo.HppPerKandangRepository } type HppCostAggregate struct { FeedCost float64 OvkCost float64 DocCost float64 DocQty float64 BudgetCost float64 ExpenseCost float64 } func NewRepportService( validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository, purchaseRepo purchaseRepo.PurchaseRepository, chickinRepo chickinRepo.ProjectChickinRepository, recordingRepo recordingRepo.RecordingRepository, approvalSvc approvalService.ApprovalService, purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, ) RepportService { return &repportService{ Log: utils.Log, Validate: validate, ExpenseRealizationRepo: expenseRealizationRepo, MarketingDeliveryRepo: marketingDeliveryRepo, PurchaseRepo: purchaseRepo, ChickinRepo: chickinRepo, RecordingRepo: recordingRepo, ApprovalSvc: approvalSvc, PurchaseSupplierRepo: purchaseSupplierRepo, HppPerKandangRepo: hppPerKandangRepo, } } func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit realizations, total, err := s.ExpenseRealizationRepo.GetAllWithFilters(c.Context(), offset, params.Limit, params) if err != nil { s.Log.Errorf("GetAllWithFilters error: %v", err) return nil, 0, err } result := dto.ToRepportExpenseListDTOs(realizations) expenseIDs := make([]uint, 0, len(result)) for i := range result { expenseIDs = append(expenseIDs, uint(result[i].Id)) } approvals, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowExpense, expenseIDs, func(db *gorm.DB) *gorm.DB { return db.Preload("ActionUser") }) if err != nil { s.Log.Warnf("LatestByTargets error: %v", err) } for i := range result { expenseIDAsUint := uint(result[i].Id) if approval, exists := approvals[expenseIDAsUint]; exists && approval != nil { mapped := approvalDTO.ToApprovalDTO(*approval) result[i].LatestApproval = &mapped } } return result, total, nil } func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit deliveryProducts, total, err := s.MarketingDeliveryRepo.GetAllWithFilters(c.Context(), offset, params.Limit, params) if err != nil { return nil, 0, err } projectFlockIDMap := make(map[uint]bool) hppMap := make(map[uint]float64) for _, dp := range deliveryProducts { if projectFlockKandang := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { projectFlockID := projectFlockKandang.ProjectFlockId if projectFlockID > 0 && !projectFlockIDMap[projectFlockID] { projectFlockIDMap[projectFlockID] = true category := projectFlockKandang.ProjectFlock.Category hppPerKg := s.calculateHppPricePerKg(c.Context(), projectFlockID, category) hppMap[projectFlockID] = hppPerKg } } } items := dto.ToRepportMarketingItemDTOsWithHppMap(deliveryProducts, hppMap) return items, total, nil } func (s *repportService) calculateHppPricePerKg(ctx context.Context, projectFlockID uint, category string) float64 { totalCost := s.getTotalProjectCost(ctx, projectFlockID) if totalCost == 0 { return 0 } chickinQty, err := s.ChickinRepo.GetTotalChickinQtyByProjectFlockID(ctx, projectFlockID) if err != nil { s.Log.Warnf("HPP calculation: Failed to get chickin qty for project flock ID %d: %v", projectFlockID, err) } depletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(ctx, projectFlockID) if err != nil { s.Log.Warnf("HPP calculation: Failed to get depletion for project flock ID %d: %v", projectFlockID, err) } avgWeight, err := s.RecordingRepo.GetLatestAvgWeightByProjectFlockID(ctx, projectFlockID) if err != nil { s.Log.Warnf("HPP calculation: Failed to get avg weight for project flock ID %d: %v", projectFlockID, err) } var totalWeight float64 if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { totalWeight = (chickinQty - depletion) * avgWeight } else { eggWeight, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(ctx, projectFlockID) if err != nil { s.Log.Warnf("HPP calculation: Failed to get egg weight for project flock ID %d: %v", projectFlockID, err) } totalWeight = (chickinQty-depletion)*avgWeight + eggWeight } if totalWeight == 0 { return 0 } hppPricePerKg := totalCost / totalWeight return hppPricePerKg } func (s *repportService) getTotalProjectCost(ctx context.Context, projectFlockID uint) float64 { if projectFlockID == 0 { return 0 } purchases, err := s.PurchaseRepo.GetItemsByProjectFlockID(ctx, projectFlockID) if err != nil { s.Log.Errorf("getTotalProjectCost: GetItemsByProjectFlockID error for project flock ID %d: %v", projectFlockID, err) return 0 } cost := float64(0) purchaseCost := float64(0) for _, p := range purchases { purchaseCost += p.TotalPrice } cost += purchaseCost realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(ctx, projectFlockID) if err != nil { s.Log.Warnf("getTotalProjectCost: GetByProjectFlockID error for project flock ID %d: %v", projectFlockID, err) } bopCost := float64(0) for _, r := range realizations { if r.ExpenseNonstock != nil && r.ExpenseNonstock.Expense != nil && r.ExpenseNonstock.Expense.Category == string(utils.ExpenseCategoryBOP) { bopCost += r.Price * r.Qty } } cost += bopCost return cost } func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit if offset < 0 { offset = 0 } suppliers, totalSuppliers, err := s.PurchaseSupplierRepo.GetSuppliersWithPurchases(c.Context(), offset, params.Limit, params) if err != nil { return nil, 0, err } if totalSuppliers == 0 || len(suppliers) == 0 { return []dto.PurchaseSupplierDTO{}, totalSuppliers, nil } supplierMap := make(map[uint]entity.Supplier, len(suppliers)) supplierIDs := make([]uint, 0, len(suppliers)) for _, supplier := range suppliers { supplierMap[supplier.Id] = supplier supplierIDs = append(supplierIDs, supplier.Id) } items, err := s.PurchaseSupplierRepo.GetItemsBySuppliers(c.Context(), supplierIDs, params) if err != nil { return nil, 0, err } itemsBySupplier := make(map[uint][]entity.PurchaseItem) for _, item := range items { if item.Purchase == nil { continue } supplierID := item.Purchase.SupplierId itemsBySupplier[supplierID] = append(itemsBySupplier[supplierID], item) } result := make([]dto.PurchaseSupplierDTO, 0, len(supplierIDs)) for _, supplierID := range supplierIDs { supplier, exists := supplierMap[supplierID] if !exists { continue } supplierItems := itemsBySupplier[supplierID] dtoItem := dto.ToPurchaseSupplierDTO(supplier, supplierItems) result = append(result, dtoItem) } return result, totalSuppliers, nil } func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { params, filters, err := s.parseHppPerKandangQuery(ctx) if err != nil { return nil, nil, err } if err := s.Validate.Struct(params); err != nil { return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) } location, err := time.LoadLocation("Asia/Jakarta") if err != nil { return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") } periodDate, err := time.ParseInLocation("2006-01-02", params.Period, location) if err != nil { return nil, nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD") } startOfDay := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, location) endOfDay := startOfDay.Add(24 * time.Hour) repoRows, err := s.HppPerKandangRepo.GetRowsByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs) if err != nil { return nil, nil, err } costRows, supplierRows, err := s.HppPerKandangRepo.GetFeedOvkDocCostByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs) if err != nil { return nil, nil, err } costMap := make(map[uint]HppCostAggregate, len(costRows)) for _, row := range costRows { costMap[row.KandangID] = HppCostAggregate{ FeedCost: row.FeedCost, OvkCost: row.OvkCost, DocCost: row.DocCost, DocQty: row.DocQty, BudgetCost: row.BudgetCost, ExpenseCost: row.ExpenseCost, } } docSupplierMap := make(map[uint][]dto.HppPerKandangSupplierDTO) feedSupplierMap := make(map[uint][]dto.HppPerKandangSupplierDTO) docSeen := make(map[uint]map[uint]bool) feedSeen := make(map[uint]map[uint]bool) for _, sup := range supplierRows { if sup.SupplierID == 0 { continue } targetMap := feedSupplierMap seen := feedSeen category := "FEED" if strings.EqualFold(sup.Category, "DOC") { targetMap = docSupplierMap seen = docSeen category = "DOC" } if seen[sup.KandangID] == nil { seen[sup.KandangID] = make(map[uint]bool) } if seen[sup.KandangID][sup.SupplierID] { continue } seen[sup.KandangID][sup.SupplierID] = true targetMap[sup.KandangID] = append(targetMap[sup.KandangID], dto.HppPerKandangSupplierDTO{ ID: int64(sup.SupplierID), Name: sup.SupplierName, Alias: sup.SupplierAlias, Category: category, }) } type weightRangeKey struct { Min float64 Max float64 } type weightRangeAggregate struct { Summary *dto.HppPerKandangSummaryWeightRangeDTO EggHppSum float64 EggHppCount int } dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) perRangeMap := make(map[weightRangeKey]*weightRangeAggregate) var totalBirds int64 var totalWeight float64 var totalEggPieces int64 var totalEggKg float64 var totalRemainingValueRp int64 var totalEggValueRp int64 var totalHppSum float64 var totalHppCount int var totalDocPriceSum float64 var totalDocPriceCount int var totalEggHppSum float64 var totalEggHppCount int for _, row := range repoRows { birdsFloat := row.RemainingChickenBirds if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { birdsFloat = 0 } weightFloat := row.RemainingChickenWeight if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { weightFloat = 0 } eggPiecesFloat := row.EggProductionPieces if math.IsNaN(eggPiecesFloat) || math.IsInf(eggPiecesFloat, 0) { eggPiecesFloat = 0 } eggWeightFloat := row.EggProductionWeightKg if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) { eggWeightFloat = 0 } avgWeight := 0.0 if birdsFloat > 0 { avgWeight = weightFloat / birdsFloat } weightMin := math.Floor(avgWeight*10) / 10 if weightMin < 0 { weightMin = 0 } weightMax := weightMin + 0.09 rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} rowBirds := int64(math.Round(birdsFloat)) costEntry := costMap[row.KandangID] totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost hppRp := 0.0 if weightFloat > 0 { hppRp = totalCost / weightFloat } eggHpp := 0.0 if eggWeightFloat > 0 { eggHpp = totalCost / eggWeightFloat } rowEggPieces := int64(math.Round(eggPiecesFloat)) rowEggValue := int64(eggHpp * eggWeightFloat) rowRemainingValue := int64(hppRp * weightFloat) avgDocPrice := int64(0) if costEntry.DocQty > 0 { avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) } dataRows = append(dataRows, dto.HppPerKandangRowDTO{ ID: int(row.KandangID), Kandang: dto.HppPerKandangRowKandangDTO{ ID: int64(row.KandangID), Name: row.KandangName, Status: row.KandangStatus, Location: dto.HppPerKandangLocationDTO{ ID: int64(row.LocationID), Name: row.LocationName, }, Pic: dto.HppPerKandangPICDTO{ ID: int64(row.PicID), Name: row.PicName, }, }, WeightRange: dto.HppPerKandangWeightRangeDTO{ WeightMin: weightMin, WeightMax: weightMax, }, RemainingChickenBirds: rowBirds, RemainingChickenWeightKg: weightFloat, AvgWeightKg: avgWeight, // FeedCostRp: costEntry.FeedCost, // OvkCostRp: costEntry.OvkCost, DocSuppliers: docSupplierMap[row.KandangID], FeedSuppliers: feedSupplierMap[row.KandangID], EggProductionPieces: rowEggPieces, EggProductionKg: eggWeightFloat, AverageDocPriceRp: avgDocPrice, HppRp: hppRp, EggHppRpPerKg: eggHpp, RemainingValueRp: rowRemainingValue, EggValueRp: rowEggValue, }) totalBirds += rowBirds totalWeight += weightFloat totalEggPieces += rowEggPieces totalEggKg += eggWeightFloat totalRemainingValueRp += rowRemainingValue totalEggValueRp += rowEggValue if weightFloat > 0 { totalHppSum += hppRp totalHppCount++ } if avgDocPrice > 0 { totalDocPriceSum += float64(avgDocPrice) totalDocPriceCount++ } if eggWeightFloat > 0 { totalEggHppSum += eggHpp totalEggHppCount++ } rangeAgg, exists := perRangeMap[rangeKey] if !exists { rangeAgg = &weightRangeAggregate{ Summary: &dto.HppPerKandangSummaryWeightRangeDTO{ WeightRange: dto.HppPerKandangWeightRangeDTO{ WeightMin: weightMin, WeightMax: weightMax, }, Label: fmt.Sprintf("%.2f - %.2f", weightMin, weightMax), }, } perRangeMap[rangeKey] = rangeAgg } rangeSummary := rangeAgg.Summary rangeSummary.RemainingChickenBirds += rowBirds rangeSummary.RemainingChickenWeightKg += row.RemainingChickenWeight rangeSummary.EggProductionPieces += rowEggPieces rangeSummary.EggProductionKg += eggWeightFloat rangeSummary.RemainingValueRp += rowRemainingValue rangeSummary.EggValueRp += rowEggValue if eggWeightFloat > 0 { rangeAgg.EggHppSum += eggHpp rangeAgg.EggHppCount++ } } rangeKeys := make([]weightRangeKey, 0, len(perRangeMap)) for key := range perRangeMap { rangeKeys = append(rangeKeys, key) } sort.Slice(rangeKeys, func(i, j int) bool { if rangeKeys[i].Min == rangeKeys[j].Min { return rangeKeys[i].Max < rangeKeys[j].Max } return rangeKeys[i].Min < rangeKeys[j].Min }) perRangeSummary := make([]dto.HppPerKandangSummaryWeightRangeDTO, 0, len(rangeKeys)) for idx, key := range rangeKeys { agg := perRangeMap[key] entry := agg.Summary entry.ID = idx + 1 if entry.RemainingChickenBirds > 0 { entry.AvgWeightKg = entry.RemainingChickenWeightKg / float64(entry.RemainingChickenBirds) } if agg.EggHppCount > 0 { entry.EggHppRpPerKg = agg.EggHppSum / float64(agg.EggHppCount) } perRangeSummary = append(perRangeSummary, *entry) } totalSummary := dto.HppPerKandangSummaryTotalDTO{ TotalRemainingChickenBirds: totalBirds, TotalRemainingChickenWeightKg: totalWeight, TotalEggProductionPieces: totalEggPieces, TotalEggProductionKg: totalEggKg, TotalRemainingValueRp: totalRemainingValueRp, TotalEggValueRp: totalEggValueRp, } if totalBirds > 0 { totalSummary.AverageWeightKg = totalWeight / float64(totalBirds) } if totalEggHppCount > 0 { totalSummary.AverageEggHppRpPerKg = totalEggHppSum / float64(totalEggHppCount) } if totalHppCount > 0 { totalSummary.TotalHppRp = totalHppSum / float64(totalHppCount) } if totalDocPriceCount > 0 { totalSummary.TotalAverageDocPriceRp = totalDocPriceSum / float64(totalDocPriceCount) } limit := params.Limit if limit <= 0 { limit = 10 } totalCount := len(dataRows) offset := (params.Page - 1) * limit if offset < 0 { offset = 0 } if offset > totalCount { offset = totalCount } end := offset + limit if end > totalCount { end = totalCount } pagedRows := dataRows[offset:end] data := dto.HppPerKandangResponseData{ Period: params.Period, Rows: pagedRows, Summary: dto.HppPerKandangSummaryDTO{ PerWeightRange: perRangeSummary, Total: totalSummary, }, } totalResults := int64(totalCount) totalPages := int64(0) if totalResults > 0 { totalPages = int64(math.Ceil(float64(totalResults) / float64(limit))) } if totalPages == 0 { totalPages = 1 } meta := &dto.HppPerKandangMetaDTO{ Page: params.Page, Limit: limit, TotalPages: totalPages, TotalResults: totalResults, Filters: filters, } return &data, meta, nil } func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.HppPerKandangQuery, dto.HppPerKandangFiltersDTO, error) { page := ctx.QueryInt("page", 1) if page < 1 { page = 1 } limit := ctx.QueryInt("limit", 10) if limit < 1 { limit = 10 } rawArea := ctx.Query("area_id", "") rawLocation := ctx.Query("location_id", "") rawKandang := ctx.Query("kandang_id", "") rawWeightMin := ctx.Query("weight_min", "") rawWeightMax := ctx.Query("weight_max", "") period := ctx.Query("period", "") showUnrecorded := ctx.QueryBool("show_unrecorded", false) areaIDs, err := parseCommaSeparatedInt64s(rawArea) if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } locationIDs, err := parseCommaSeparatedInt64s(rawLocation) if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } kandangIDs, err := parseCommaSeparatedInt64s(rawKandang) if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } weightMin, err := parseOptionalFloat64(rawWeightMin) if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } weightMax, err := parseOptionalFloat64(rawWeightMax) if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } params := &validation.HppPerKandangQuery{ Page: page, Limit: limit, Period: period, ShowUnrecorded: showUnrecorded, AreaIDs: areaIDs, LocationIDs: locationIDs, KandangIDs: kandangIDs, WeightMin: weightMin, WeightMax: weightMax, } showUnrecordedFilter := "" if showUnrecorded { showUnrecordedFilter = "true" } filters := dto.NewHppPerKandangFiltersDTO( rawArea, rawLocation, rawKandang, rawWeightMin, rawWeightMax, period, showUnrecordedFilter, ) return params, filters, nil } func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } parts := strings.Split(raw, ",") result := make([]int64, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } id, err := strconv.ParseInt(part, 10, 64) if err != nil { return nil, fmt.Errorf("invalid integer value '%s'", part) } result = append(result, id) } return result, nil } func parseOptionalFloat64(raw string) (*float64, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } value, err := strconv.ParseFloat(raw, 64) if err != nil { return nil, fmt.Errorf("invalid float value '%s'", raw) } return &value, nil }