From 10f42ed9c4b6087ed1b4e1a34de82600efbfecea Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Sun, 28 Dec 2025 18:41:46 +0700 Subject: [PATCH] feat[BE-378]:Create API Get All HPP Harian Kandang --- .../controllers/repport.controller.go | 23 + .../modules/repports/dto/repportHpp.dto.go | 123 +++++ internal/modules/repports/module.go | 3 +- .../hpp_per_kandang.repository.go | 361 ++++++++++++++ internal/modules/repports/route.go | 2 + .../repports/services/repport.service.go | 454 ++++++++++++++++++ .../validations/repport.validation.go | 12 + 7 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 internal/modules/repports/dto/repportHpp.dto.go create mode 100644 internal/modules/repports/repositories/hpp_per_kandang.repository.go diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 0ab2ccbd..82229a45 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -164,3 +164,26 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error { Data: result, }) } + +func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error { + data, meta, err := c.RepportService.GetHppPerKandang(ctx) + if err != nil { + return err + } + + resp := struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta dto.HppPerKandangMetaDTO `json:"meta"` + Data dto.HppPerKandangResponseData `json:"data"` + }{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get HPP harian kandang layer successfully", + Meta: *meta, + Data: *data, + } + + return ctx.Status(fiber.StatusOK).JSON(resp) +} diff --git a/internal/modules/repports/dto/repportHpp.dto.go b/internal/modules/repports/dto/repportHpp.dto.go new file mode 100644 index 00000000..63c5dce9 --- /dev/null +++ b/internal/modules/repports/dto/repportHpp.dto.go @@ -0,0 +1,123 @@ +package dto + +type HppPerKandangFiltersDTO struct { + AreaID string `json:"area_id"` + LocationID string `json:"location_id"` + KandangID string `json:"kandang_id"` + WeightMin string `json:"weight_min"` + WeightMax string `json:"weight_max"` + Period string `json:"period"` + ShowUnrecorded string `json:"show_unrecorded"` +} + +type HppPerKandangMetaDTO struct { + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int64 `json:"total_pages"` + TotalResults int64 `json:"total_results"` + Filters HppPerKandangFiltersDTO `json:"filters"` +} + +type HppPerKandangResponseData struct { + Period string `json:"period"` + Rows []HppPerKandangRowDTO `json:"rows"` + Summary HppPerKandangSummaryDTO `json:"summary"` +} + +type HppPerKandangRowDTO struct { + ID int `json:"id"` + Kandang HppPerKandangRowKandangDTO `json:"kandang"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + RemainingChickenBirds int64 `json:"remaining_chicken_birds"` + RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` + AvgWeightKg float64 `json:"avg_weight_kg"` + EggProductionPieces int64 `json:"egg_production_pieces"` + EggProductionKg float64 `json:"egg_production_kg"` + // FeedCostRp float64 `json:"feed_cost_rp"` + // OvkCostRp float64 `json:"ovk_cost_rp"` + EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` + EggValueRp int64 `json:"egg_value_rp"` + FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` + DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` + AverageDocPriceRp int64 `json:"average_doc_price_rp"` + HppRp float64 `json:"hpp_rp"` + RemainingValueRp int64 `json:"remaining_value_rp"` +} + +type HppPerKandangRowKandangDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Location HppPerKandangLocationDTO `json:"location"` + Pic HppPerKandangPICDTO `json:"pic"` +} + +type HppPerKandangLocationDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type HppPerKandangPICDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type HppPerKandangWeightRangeDTO struct { + WeightMin float64 `json:"weight_min"` + WeightMax float64 `json:"weight_max"` +} + +type HppPerKandangSupplierDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` +} + +type HppPerKandangSummaryDTO struct { + PerWeightRange []HppPerKandangSummaryWeightRangeDTO `json:"per_weight_range"` + Total HppPerKandangSummaryTotalDTO `json:"total"` +} + +type HppPerKandangSummaryWeightRangeDTO struct { + ID int `json:"id"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + Label string `json:"label"` + RemainingChickenBirds int64 `json:"remaining_chicken_birds"` + RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` + AvgWeightKg float64 `json:"avg_weight_kg"` + EggProductionPieces int64 `json:"egg_production_pieces"` + EggProductionKg float64 `json:"egg_production_kg"` + EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` + EggValueRp int64 `json:"egg_value_rp"` + FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` + DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` + AverageDocPriceRp float64 `json:"average_doc_price_rp"` + HppRp float64 `json:"hpp_rp"` + RemainingValueRp int64 `json:"remaining_value_rp"` +} + +type HppPerKandangSummaryTotalDTO struct { + TotalRemainingChickenBirds int64 `json:"total_remaining_chicken_birds"` + TotalRemainingChickenWeightKg float64 `json:"total_remaining_chicken_weight_kg"` + AverageWeightKg float64 `json:"average_weight_kg"` + TotalRemainingValueRp int64 `json:"total_remaining_value_rp"` + TotalEggProductionPieces int64 `json:"total_egg_production_pieces"` + TotalEggProductionKg float64 `json:"total_egg_production_kg"` + AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"` + TotalEggValueRp int64 `json:"total_egg_value_rp"` + TotalHppRp float64 `json:"total_hpp_rp"` + TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"` +} + +func NewHppPerKandangFiltersDTO(area, location, kandang, weightMin, weightMax, period, showUnrecorded string) HppPerKandangFiltersDTO { + return HppPerKandangFiltersDTO{ + AreaID: area, + LocationID: location, + KandangID: kandang, + WeightMin: weightMin, + WeightMax: weightMax, + Period: period, + ShowUnrecorded: showUnrecorded, + } +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 1e019c90..105d9ad5 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -31,10 +31,11 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * recordingRepository := recordingRepo.NewRecordingRepository(db) approvalRepository := commonRepo.NewApprovalRepository(db) purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) + hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) userRepository := rUser.NewUserRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository) + repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, hppPerKandangRepository) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go new file mode 100644 index 00000000..7e1c8143 --- /dev/null +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -0,0 +1,361 @@ +package repositories + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type HppPerKandangRow struct { + KandangID uint + KandangName string + KandangStatus string + LocationID uint + LocationName string + PicID uint + PicName string + RemainingChickenBirds float64 + RemainingChickenWeight float64 + EggProductionWeightKg float64 + EggProductionPieces float64 +} + +type HppPerKandangCostRow struct { + KandangID uint + FeedCost float64 + OvkCost float64 + DocCost float64 + DocQty float64 + BudgetCost float64 + ExpenseCost float64 +} + +type HppPerKandangSupplierRow struct { + KandangID uint + SupplierID uint + SupplierName string + SupplierAlias string + Category string +} + +type HppPerKandangRepository interface { + GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) + GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) +} + +type hppPerKandangRepository struct { + db *gorm.DB +} + +func NewHppPerKandangRepository(db *gorm.DB) HppPerKandangRepository { + return &hppPerKandangRepository{db: db} +} + +func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) { + var rows []HppPerKandangRow + + query := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + k.id AS kandang_id, + k.name AS kandang_name, + k.status AS kandang_status, + loc.id AS location_id, + loc.name AS location_name, + pic.id AS pic_id, + pic.name AS pic_name, + COALESCE(MAX(r.total_chick_qty), 0) AS remaining_chicken_birds, + COALESCE(SUM(rbw.total_weight), 0) AS remaining_chicken_weight, + COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg, + COALESCE(SUM(re.qty), 0) AS egg_production_pieces`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("JOIN users AS pic ON pic.id = k.pic_id"). + Joins("LEFT JOIN recording_bws AS rbw ON rbw.recording_id = r.id"). + Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) + + query = query.Group("k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). + Order("k.id ASC") + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) { + var rows []HppPerKandangCostRow + + recordingPfk := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("DISTINCT pfk.id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs) + + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS").String() + transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_DETAILS").String() + + query := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + k.id AS kandang_id, + COALESCE(SUM(CASE + WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END), 0) AS feed_cost, + COALESCE(SUM(CASE + WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END), 0) AS ovk_cost`, + utils.FlagPakan, transferStockableKey, utils.FlagPakan, + utils.FlagOVK, transferStockableKey, utils.FlagOVK). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). + Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey). + Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct). + Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) + + query = query.Group("k.id").Order("k.id ASC") + + if err := query.Scan(&rows).Error; err != nil { + return nil, nil, err + } + + docRows := make([]struct { + KandangID uint + DocCost float64 + DocQty float64 + SupplierID *uint + SupplierName *string + SupplierAlias *string + }, 0) + + docQuery := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select(` + pfk.kandang_id AS kandang_id, + COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost, + COALESCE(SUM(pc.usage_qty), 0) AS doc_qty, + s.id AS supplier_id, + s.name AS supplier_name, + s.alias AS supplier_alias`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). + Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). + Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). + Where("pc.project_flock_kandang_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Group("pfk.kandang_id, s.id, s.name, s.alias") + docQuery = applyLocationFilters(docQuery, areaIDs, locationIDs, kandangIDs) + + if err := docQuery.Scan(&docRows).Error; err != nil { + return nil, nil, err + } + + costMap := make(map[uint]*HppPerKandangCostRow, len(rows)) + for i := range rows { + row := rows[i] + costMap[row.KandangID] = &rows[i] + } + + docSuppliers := make([]HppPerKandangSupplierRow, 0) + docSeen := make(map[uint]map[uint]bool) + for _, doc := range docRows { + entry, ok := costMap[doc.KandangID] + if !ok { + rows = append(rows, HppPerKandangCostRow{ + KandangID: doc.KandangID, + }) + entry = &rows[len(rows)-1] + costMap[doc.KandangID] = entry + } + entry.DocCost += doc.DocCost + entry.DocQty += doc.DocQty + if doc.SupplierID != nil { + if docSeen[doc.KandangID] == nil { + docSeen[doc.KandangID] = make(map[uint]bool) + } + if !docSeen[doc.KandangID][*doc.SupplierID] { + docSeen[doc.KandangID][*doc.SupplierID] = true + supplierName := "" + if doc.SupplierName != nil { + supplierName = *doc.SupplierName + } + supplierAlias := "" + if doc.SupplierAlias != nil { + supplierAlias = *doc.SupplierAlias + } + docSuppliers = append(docSuppliers, HppPerKandangSupplierRow{ + KandangID: doc.KandangID, + SupplierID: *doc.SupplierID, + SupplierName: supplierName, + SupplierAlias: supplierAlias, + Category: "DOC", + }) + } + } + } + + budgetRows := make([]struct { + KandangID uint + BudgetCost float64 + }, 0) + + pfkUsageSub := r.db. + Table("project_chickins AS pc"). + Select(` + pc.project_flock_kandang_id, + SUM(pc.usage_qty) AS kandang_usage_qty`). + Group("pc.project_flock_kandang_id") + + projectUsageSub := r.db. + Table("project_chickins AS pc"). + Select(` + pfk.project_flock_id, + SUM(pc.usage_qty) AS project_usage_qty`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Group("pfk.project_flock_id") + + budgetQuery := r.db.WithContext(ctx). + Table("project_flock_kandangs AS pfk"). + Select(` + k.id AS kandang_id, + COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0) AS budget_cost`). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id"). + Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub). + Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub). + Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Group("k.id") + budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) + + if err := budgetQuery.Scan(&budgetRows).Error; err != nil { + return nil, nil, err + } + + for _, budget := range budgetRows { + entry, ok := costMap[budget.KandangID] + if !ok { + rows = append(rows, HppPerKandangCostRow{ + KandangID: budget.KandangID, + }) + entry = &rows[len(rows)-1] + costMap[budget.KandangID] = entry + } + entry.BudgetCost += budget.BudgetCost + } + + expenseRows := make([]struct { + KandangID uint + ExpenseCost float64 + }, 0) + + expenseQuery := r.db.WithContext(ctx). + Table("project_flock_kandangs AS pfk"). + Select(` + k.id AS kandang_id, + COALESCE(SUM(er.qty * er.price), 0) AS expense_cost`). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id"). + Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). + Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Group("k.id") + expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) + + if err := expenseQuery.Scan(&expenseRows).Error; err != nil { + return nil, nil, err + } + + for _, exp := range expenseRows { + entry, ok := costMap[exp.KandangID] + if !ok { + rows = append(rows, HppPerKandangCostRow{ + KandangID: exp.KandangID, + }) + entry = &rows[len(rows)-1] + costMap[exp.KandangID] = entry + } + entry.ExpenseCost += exp.ExpenseCost + } + + feedSuppliers := make([]HppPerKandangSupplierRow, 0) + + feedQuery := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("DISTINCT k.id AS kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). + Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). + Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}). + Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs) + + if err := feedQuery.Scan(&feedSuppliers).Error; err != nil { + return nil, nil, err + } + + for i := range feedSuppliers { + if _, exists := costMap[feedSuppliers[i].KandangID]; !exists { + rows = append(rows, HppPerKandangCostRow{ + KandangID: feedSuppliers[i].KandangID, + }) + costMap[feedSuppliers[i].KandangID] = &rows[len(rows)-1] + } + feedSuppliers[i].Category = "FEED" + } + + supplierRows := append(docSuppliers, feedSuppliers...) + + return rows, supplierRows, nil +} + +func applyLocationFilters(query *gorm.DB, areaIDs, locationIDs, kandangIDs []int64) *gorm.DB { + if len(areaIDs) > 0 { + query = query.Where("loc.area_id IN ?", areaIDs) + } + if len(locationIDs) > 0 { + query = query.Where("k.location_id IN ?", locationIDs) + } + if len(kandangIDs) > 0 { + query = query.Where("k.id IN ?", kandangIDs) + } + return query +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 45dc32b7..707ef878 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -18,4 +18,6 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense) route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing) route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) + route.Get("/hpp-per-kandang", ctrl.GetHppPerKandang) + } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index fbca69b7..e2232a02 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -2,6 +2,12 @@ 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" @@ -28,6 +34,7 @@ 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 { @@ -40,6 +47,16 @@ type repportService struct { 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( @@ -51,6 +68,7 @@ func NewRepportService( recordingRepo recordingRepo.RecordingRepository, approvalSvc approvalService.ApprovalService, purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, + hppPerKandangRepo repportRepo.HppPerKandangRepository, ) RepportService { return &repportService{ Log: utils.Log, @@ -62,6 +80,7 @@ func NewRepportService( RecordingRepo: recordingRepo, ApprovalSvc: approvalSvc, PurchaseSupplierRepo: purchaseSupplierRepo, + HppPerKandangRepo: hppPerKandangRepo, } } @@ -265,3 +284,438 @@ func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.Pu 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 +} diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index f1f46c6d..47a711cc 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -42,3 +42,15 @@ type PurchaseSupplierQuery struct { SortBy string `query:"sort_by" validate:"omitempty"` FilterBy string `query:"filter_by" validate:"omitempty"` } + +type HppPerKandangQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Period string `query:"period" validate:"required"` + ShowUnrecorded bool `query:"show_unrecorded"` + AreaIDs []int64 `query:"-"` + LocationIDs []int64 `query:"-"` + KandangIDs []int64 `query:"-"` + WeightMin *float64 `query:"-"` + WeightMax *float64 `query:"-"` +}