From 93ed89b4efe010bdb8bbeded290ebe2a527f2654 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 3 Jun 2026 00:30:41 +0700 Subject: [PATCH 1/2] ini api per farm --- .../controllers/repport.controller.go | 23 + .../repports/dto/repportHppPerFarm.dto.go | 79 +++ internal/modules/repports/module.go | 2 + .../repositories/hpp_per_farm.repository.go | 233 +++++++++ internal/modules/repports/route.go | 1 + .../repports/services/hpp_per_farm_test.go | 93 ++++ .../repports/services/repport.service.go | 478 ++++++++++++++++++ .../validations/repport.validation.go | 9 + 8 files changed, 918 insertions(+) create mode 100644 internal/modules/repports/dto/repportHppPerFarm.dto.go create mode 100644 internal/modules/repports/repositories/hpp_per_farm.repository.go create mode 100644 internal/modules/repports/services/hpp_per_farm_test.go diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 4fb0e167..79a10b63 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -457,6 +457,29 @@ func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error { return ctx.Status(fiber.StatusOK).JSON(resp) } +func (c *RepportController) GetHppPerFarm(ctx *fiber.Ctx) error { + data, meta, err := c.RepportService.GetHppPerFarm(ctx) + if err != nil { + return err + } + + resp := struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta dto.HppPerFarmMetaDTO `json:"meta"` + Data dto.HppPerFarmResponseData `json:"data"` + }{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get HPP per farm successfully", + Meta: *meta, + Data: *data, + } + + return ctx.Status(fiber.StatusOK).JSON(resp) +} + func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { var customerIDs []uint if customerIDsStr := ctx.Query("customer_ids"); customerIDsStr != "" { diff --git a/internal/modules/repports/dto/repportHppPerFarm.dto.go b/internal/modules/repports/dto/repportHppPerFarm.dto.go new file mode 100644 index 00000000..8e047274 --- /dev/null +++ b/internal/modules/repports/dto/repportHppPerFarm.dto.go @@ -0,0 +1,79 @@ +package dto + +type HppPerFarmFiltersDTO struct { + AreaID string `json:"area_id"` + LocationID string `json:"location_id"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` +} + +type HppPerFarmMetaDTO struct { + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int64 `json:"total_pages"` + TotalResults int64 `json:"total_results"` + Filters HppPerFarmFiltersDTO `json:"filters"` +} + +type HppPerFarmResponseData struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + Rows []HppPerFarmRowDTO `json:"rows"` + Summary HppPerFarmSummaryDTO `json:"summary"` +} + +// HppPerFarmRowDTO is one farm (location) row, aggregating all LAYING project +// flocks within the same location over the selected date range. +type HppPerFarmRowDTO struct { + Location HppPerKandangLocationDTO `json:"location"` + // total_cost_rp = depreciation + pakan + ovk + bop (+ other production cost). + // DOC/pullet is NOT included here (it is expensed through depreciation); + // average_doc_price_rp is provided for information only. + TotalCostRp float64 `json:"total_cost_rp"` + FeedCostRp float64 `json:"feed_cost_rp"` + OvkCostRp float64 `json:"ovk_cost_rp"` + BopCostRp float64 `json:"bop_cost_rp"` + DepreciationRp float64 `json:"depreciation_rp"` + OtherCostRp float64 `json:"other_cost_rp"` + EggWeightRecordingKg float64 `json:"egg_weight_recording_kg"` + EggWeightDoKg float64 `json:"egg_weight_do_kg"` + HppPerKgProduction float64 `json:"hpp_per_kg_production"` + HppPerKgSales float64 `json:"hpp_per_kg_sales"` + AverageDocPriceRp int64 `json:"average_doc_price_rp"` + + Flocks []HppPerFarmFlockDTO `json:"flocks"` +} + +// HppPerFarmFlockDTO is the per-project-flock breakdown inside a farm row. +type HppPerFarmFlockDTO struct { + ProjectFlockID int64 `json:"project_flock_id"` + FlockName string `json:"flock_name"` + TotalCostRp float64 `json:"total_cost_rp"` + FeedCostRp float64 `json:"feed_cost_rp"` + OvkCostRp float64 `json:"ovk_cost_rp"` + BopCostRp float64 `json:"bop_cost_rp"` + DepreciationRp float64 `json:"depreciation_rp"` + OtherCostRp float64 `json:"other_cost_rp"` + EggWeightRecordingKg float64 `json:"egg_weight_recording_kg"` + EggWeightDoKg float64 `json:"egg_weight_do_kg"` + HppPerKgProduction float64 `json:"hpp_per_kg_production"` + HppPerKgSales float64 `json:"hpp_per_kg_sales"` + AverageDocPriceRp int64 `json:"average_doc_price_rp"` +} + +type HppPerFarmSummaryDTO struct { + TotalCostRp float64 `json:"total_cost_rp"` + TotalEggWeightRecordingKg float64 `json:"total_egg_weight_recording_kg"` + TotalEggWeightDoKg float64 `json:"total_egg_weight_do_kg"` + AverageHppPerKgProduction float64 `json:"average_hpp_per_kg_production"` + AverageHppPerKgSales float64 `json:"average_hpp_per_kg_sales"` +} + +func NewHppPerFarmFiltersDTO(area, location, startDate, endDate string) HppPerFarmFiltersDTO { + return HppPerFarmFiltersDTO{ + AreaID: area, + LocationID: location, + StartDate: startDate, + EndDate: endDate, + } +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 62f26794..bdb5550e 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -37,6 +37,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) + hppPerFarmRepository := repportRepo.NewHppPerFarmRepository(db) expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) @@ -65,6 +66,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, + hppPerFarmRepository, productionResultRepository, customerPaymentRepository, balanceMonitoringRepository, diff --git a/internal/modules/repports/repositories/hpp_per_farm.repository.go b/internal/modules/repports/repositories/hpp_per_farm.repository.go new file mode 100644 index 00000000..9ed2d557 --- /dev/null +++ b/internal/modules/repports/repositories/hpp_per_farm.repository.go @@ -0,0 +1,233 @@ +package repositories + +import ( + "context" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + 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" +) + +// HppPerFarmFlockMetaRow describes a LAYING project flock and the farm +// (location) it belongs to. Farm identity is project_flocks.location_id. +type HppPerFarmFlockMetaRow struct { + ProjectFlockID uint + FlockName string + LocationID uint + LocationName string + AreaID uint +} + +// HppPerFarmDocRow holds the DOC/pullet acquisition cost trace per flock. +// Used only as an informational field (average_doc_price_rp); it is NOT part +// of total_cost because the pullet cost is expensed through depreciation. +type HppPerFarmDocRow struct { + ProjectFlockID uint + DocCost float64 + DocQty float64 +} + +type HppPerFarmRepository interface { + GetCandidateFlocks(ctx context.Context, start time.Time, areaIDs, locationIDs []int64) ([]HppPerFarmFlockMetaRow, error) + SumRecordingEggWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error) + SumMarketingDoTelurWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error) + GetDocCostByFlock(ctx context.Context, projectFlockIDs []uint) (map[uint]HppPerFarmDocRow, error) + DB() *gorm.DB +} + +type hppPerFarmRepository struct { + db *gorm.DB +} + +func NewHppPerFarmRepository(db *gorm.DB) HppPerFarmRepository { + return &hppPerFarmRepository{db: db} +} + +func (r *hppPerFarmRepository) DB() *gorm.DB { + return r.db +} + +// GetCandidateFlocks returns the LAYING project flocks (with their farm/location +// metadata) that are still active on or after the range start, scoped by area +// and location. Mirrors ExpenseDepreciationRepository.GetCandidateFarms but adds +// location info so flocks can be grouped per farm. +func (r *hppPerFarmRepository) GetCandidateFlocks(ctx context.Context, start time.Time, areaIDs, locationIDs []int64) ([]HppPerFarmFlockMetaRow, error) { + rows := make([]HppPerFarmFlockMetaRow, 0) + + query := r.db.WithContext(ctx). + Table("project_flocks AS pf"). + Select(` + DISTINCT pf.id AS project_flock_id, + pf.flock_name AS flock_name, + pf.location_id AS location_id, + loc.name AS location_name, + pf.area_id AS area_id`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Joins("JOIN locations AS loc ON loc.id = pf.location_id"). + Where("pf.deleted_at IS NULL"). + Where("pf.category = ?", utils.ProjectFlockCategoryLaying). + Where("(pfk.closed_at IS NULL OR DATE(pfk.closed_at) >= DATE(?))", start) + + if len(areaIDs) > 0 { + query = query.Where("pf.area_id IN ?", areaIDs) + } + if len(locationIDs) > 0 { + query = query.Where("pf.location_id IN ?", locationIDs) + } + + if err := query.Order("pf.location_id ASC, pf.id ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +// SumRecordingEggWeightByFlock sums recording_eggs.weight (kg) per project flock +// for non-rejected recordings whose record_datetime falls inside [start, endExclusive). +func (r *hppPerFarmRepository) SumRecordingEggWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error) { + result := make(map[uint]float64) + if len(projectFlockIDs) == 0 { + return result, nil + } + + latestApproval := r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowRecording), + ) + + type eggRow struct { + ProjectFlockID uint + Weight float64 + } + rows := make([]eggRow, 0) + + query := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + pfk.project_flock_id AS project_flock_id, + COALESCE(SUM(re.weight), 0) AS weight`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). + Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). + Where("pfk.project_flock_id IN ?", projectFlockIDs). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, endExclusive). + Where("r.deleted_at IS NULL"). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Group("pfk.project_flock_id") + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + result[row.ProjectFlockID] = row.Weight + } + return result, nil +} + +// SumMarketingDoTelurWeightByFlock sums delivered TELUR weight (marketing_delivery_products.total_weight) +// per project flock, for delivery_date inside [start, endExclusive). A delivery product that is +// attributed to multiple flocks is prorated by each flock's allocated qty share, so that +// the farm total equals the sum of its flocks. +func (r *hppPerFarmRepository) SumMarketingDoTelurWeightByFlock(ctx context.Context, start, endExclusive time.Time, projectFlockIDs []uint) (map[uint]float64, error) { + result := make(map[uint]float64) + if len(projectFlockIDs) == 0 { + return result, nil + } + + telurFlags := []string{ + string(utils.FlagTelur), + string(utils.FlagTelurUtuh), + string(utils.FlagTelurPecah), + string(utils.FlagTelurPutih), + string(utils.FlagTelurRetak), + } + + // allocated qty per (marketing_delivery_product, project_flock) + attrByFlock := r.db.WithContext(ctx). + Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.db.WithContext(ctx))). + Select(` + mda.marketing_delivery_product_id AS mdp_id, + mda.project_flock_id AS project_flock_id, + SUM(mda.allocated_qty) AS flock_qty`). + Group("mda.marketing_delivery_product_id, mda.project_flock_id") + + // prorate each delivery product's total_weight across its attributed flocks. + // Use EXISTS for the TELUR flag filter (not a JOIN) so a product carrying + // multiple egg flags does not fan out and double-count the weight share. + shareQuery := r.db.WithContext(ctx). + Table("(?) AS a", attrByFlock). + Select(` + a.project_flock_id AS project_flock_id, + mdp.total_weight * a.flock_qty / NULLIF(SUM(a.flock_qty) OVER (PARTITION BY a.mdp_id), 0) AS weight_share`). + Joins("JOIN marketing_delivery_products AS mdp ON mdp.id = a.mdp_id"). + Joins("JOIN marketing_products AS mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN product_warehouses AS pw ON pw.id = mp.product_warehouse_id"). + Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, telurFlags). + Where("mdp.delivery_date >= ? AND mdp.delivery_date < ?", start, endExclusive) + + type doRow struct { + ProjectFlockID uint + Weight float64 + } + rows := make([]doRow, 0) + + query := r.db.WithContext(ctx). + Table("(?) AS s", shareQuery). + Select(` + s.project_flock_id AS project_flock_id, + COALESCE(SUM(s.weight_share), 0) AS weight`). + Where("s.project_flock_id IN ?", projectFlockIDs). + Group("s.project_flock_id") + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + result[row.ProjectFlockID] = row.Weight + } + return result, nil +} + +// GetDocCostByFlock returns the DOC acquisition cost (qty * purchase price) and qty +// traced to chick-in per project flock. Informational only. +func (r *hppPerFarmRepository) GetDocCostByFlock(ctx context.Context, projectFlockIDs []uint) (map[uint]HppPerFarmDocRow, error) { + result := make(map[uint]HppPerFarmDocRow) + if len(projectFlockIDs) == 0 { + return result, nil + } + + rows := make([]HppPerFarmDocRow, 0) + query := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select(` + pfk.project_flock_id AS project_flock_id, + COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS doc_cost, + COALESCE(SUM(sa.qty), 0) AS doc_qty`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Where("pfk.project_flock_id IN ?", projectFlockIDs). + Group("pfk.project_flock_id") + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + result[row.ProjectFlockID] = row + } + return result, nil +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 56faae35..9b4b6232 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -23,6 +23,7 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) + route.Get("/hpp-per-farm", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerFarm) route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) diff --git a/internal/modules/repports/services/hpp_per_farm_test.go b/internal/modules/repports/services/hpp_per_farm_test.go new file mode 100644 index 00000000..a3a06f12 --- /dev/null +++ b/internal/modules/repports/services/hpp_per_farm_test.go @@ -0,0 +1,93 @@ +package service + +import ( + "math" + "testing" + + approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" +) + +// production-scope total should sum only parts tagged production_cost (a part +// tagged with both scopes still counts once). +func TestHppPerFarmProductionScopeTotalPartLevelScopes(t *testing.T) { + comp := &approvalService.HppV2Component{ + Code: "PAKAN", + Parts: []approvalService.HppV2ComponentPart{ + {Total: 100, Scopes: []string{"production_cost"}}, + {Total: 50, Scopes: []string{"pullet_cost"}}, + {Total: 25, Scopes: []string{"production_cost", "pullet_cost"}}, + }, + } + if got := hppPerFarmProductionScopeTotal(comp); got != 125 { + t.Fatalf("expected 125, got %v", got) + } +} + +// when parts carry no scopes, fall back to the component-level scope. +func TestHppPerFarmProductionScopeTotalComponentLevelFallback(t *testing.T) { + prod := &approvalService.HppV2Component{ + Code: "DIRECT_PULLET_PURCHASE", + Scopes: []string{"production_cost"}, + Total: 300, + Parts: []approvalService.HppV2ComponentPart{{Total: 300}}, + } + if got := hppPerFarmProductionScopeTotal(prod); got != 300 { + t.Fatalf("expected 300 component fallback, got %v", got) + } + + // DOC/pullet is pullet-scope only -> contributes 0 to production cost, + // which is exactly why it must not be added to total_cost (depreciation + // already expenses the pullet). + pulletOnly := &approvalService.HppV2Component{ + Code: "DOC_CHICKIN", + Scopes: []string{"pullet_cost"}, + Total: 999, + Parts: []approvalService.HppV2ComponentPart{{Total: 999}}, + } + if got := hppPerFarmProductionScopeTotal(pulletOnly); got != 0 { + t.Fatalf("expected 0 for pullet-only component, got %v", got) + } +} + +func TestHppPerFarmProductionScopeTotalsByCode(t *testing.T) { + b := &approvalService.HppV2Breakdown{ + Components: []approvalService.HppV2Component{ + {Code: "PAKAN", Parts: []approvalService.HppV2ComponentPart{{Total: 100, Scopes: []string{"production_cost"}}}}, + {Code: "OVK", Parts: []approvalService.HppV2ComponentPart{{Total: 40, Scopes: []string{"production_cost"}}}}, + {Code: "DOC_CHICKIN", Scopes: []string{"pullet_cost"}, Total: 500, Parts: []approvalService.HppV2ComponentPart{{Total: 500}}}, + {Code: "DEPRECIATION", Scopes: []string{"production_cost"}, Total: 30, Parts: []approvalService.HppV2ComponentPart{{Total: 30, Scopes: []string{"production_cost"}}}}, + }, + } + got := hppPerFarmProductionScopeTotalsByCode(b) + if got["PAKAN"] != 100 { + t.Fatalf("expected PAKAN 100, got %v", got["PAKAN"]) + } + if got["OVK"] != 40 { + t.Fatalf("expected OVK 40, got %v", got["OVK"]) + } + if got["DOC_CHICKIN"] != 0 { + t.Fatalf("expected DOC_CHICKIN production scope 0, got %v", got["DOC_CHICKIN"]) + } + if got["DEPRECIATION"] != 30 { + t.Fatalf("expected DEPRECIATION 30, got %v", got["DEPRECIATION"]) + } +} + +func TestHppPerFarmSafeDiv(t *testing.T) { + cases := []struct { + num, den, want float64 + }{ + {100, 4, 25}, + {100, 0, 0}, + {100, -5, 0}, + {0, 0, 0}, + } + for _, c := range cases { + if got := hppPerFarmSafeDiv(c.num, c.den); got != c.want { + t.Fatalf("safeDiv(%v,%v)=%v want %v", c.num, c.den, got, c.want) + } + } + if got := hppPerFarmSafeDiv(math.Inf(1), 1); got != 0 { + t.Fatalf("expected 0 for inf numerator, got %v", got) + } +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 198dfa82..1d158e92 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -49,6 +49,7 @@ type RepportService interface { GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) + GetHppPerFarm(ctx *fiber.Ctx) (*dto.HppPerFarmResponseData, *dto.HppPerFarmMetaDTO, error) GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) @@ -73,6 +74,7 @@ type repportService struct { PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository DebtSupplierRepo repportRepo.DebtSupplierRepository HppPerKandangRepo repportRepo.HppPerKandangRepository + HppPerFarmRepo repportRepo.HppPerFarmRepository ProductionResultRepo repportRepo.ProductionResultRepository CustomerPaymentRepo repportRepo.CustomerPaymentRepository BalanceMonitoringRepo repportRepo.BalanceMonitoringRepository @@ -106,6 +108,7 @@ func NewRepportService( purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, debtSupplierRepo repportRepo.DebtSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, + hppPerFarmRepo repportRepo.HppPerFarmRepository, productionResultRepo repportRepo.ProductionResultRepository, customerPaymentRepo repportRepo.CustomerPaymentRepository, balanceMonitoringRepo repportRepo.BalanceMonitoringRepository, @@ -130,6 +133,7 @@ func NewRepportService( PurchaseSupplierRepo: purchaseSupplierRepo, DebtSupplierRepo: debtSupplierRepo, HppPerKandangRepo: hppPerKandangRepo, + HppPerFarmRepo: hppPerFarmRepo, ProductionResultRepo: productionResultRepo, CustomerPaymentRepo: customerPaymentRepo, BalanceMonitoringRepo: balanceMonitoringRepo, @@ -2945,6 +2949,480 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return params, filters, nil } +const ( + hppPerFarmProductionScope = "production_cost" + hppPerFarmComponentDepreciation = "DEPRECIATION" + hppPerFarmComponentPakan = "PAKAN" + hppPerFarmComponentOvk = "OVK" + hppPerFarmComponentBopRegular = "BOP_REGULAR" + hppPerFarmComponentBopEkspedisi = "BOP_EKSPEDISI" + hppPerFarmMaxRangeDays = 366 +) + +// GetHppPerFarm builds the HPP-per-farm report: it groups all LAYING project +// flocks by location/farm over [start_date, end_date] and reports, per farm, +// the total cost (pakan + ovk + bop + depreciation) and two cost-per-kg figures +// — one against egg weight produced (recording_eggs) and one against egg weight +// sold/delivered (marketing delivery orders). DOC/pullet cost is informational +// only (it is expensed through depreciation, so it is NOT added to total cost). +func (s *repportService) GetHppPerFarm(ctx *fiber.Ctx) (*dto.HppPerFarmResponseData, *dto.HppPerFarmMetaDTO, error) { + params, filters, err := s.parseHppPerFarmQuery(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()) + } + if s.HppPerFarmRepo == nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "hpp per farm repository is not configured") + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + startDate, err := time.ParseInLocation("2006-01-02", params.StartDate, location) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "start_date must follow format YYYY-MM-DD") + } + endDate, err := time.ParseInLocation("2006-01-02", params.EndDate, location) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must follow format YYYY-MM-DD") + } + if endDate.Before(startDate) { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date") + } + rangeDays := int(endDate.Sub(startDate).Hours()/24) + 1 + if rangeDays > hppPerFarmMaxRangeDays { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "date range must not exceed 366 days") + } + + startOfRange := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, location) + endBreakdownDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location) + endExclusive := endBreakdownDate.Add(24 * time.Hour) + startBreakdownDate := startOfRange.AddDate(0, 0, -1) + + limit := params.Limit + if limit <= 0 { + limit = 10 + } + + flockRows, err := s.HppPerFarmRepo.GetCandidateFlocks(ctx.Context(), startOfRange, params.AreaIDs, params.LocationIDs) + if err != nil { + return nil, nil, err + } + if len(flockRows) == 0 { + meta := &dto.HppPerFarmMetaDTO{ + Page: params.Page, + Limit: limit, + TotalPages: 1, + TotalResults: 0, + Filters: filters, + } + data := &dto.HppPerFarmResponseData{ + StartDate: params.StartDate, + EndDate: params.EndDate, + Rows: []dto.HppPerFarmRowDTO{}, + Summary: dto.HppPerFarmSummaryDTO{}, + } + return data, meta, nil + } + + flockIDs := make([]uint, 0, len(flockRows)) + for _, row := range flockRows { + flockIDs = append(flockIDs, row.ProjectFlockID) + } + + depByFlock, err := s.sumHppPerFarmDepreciationOverRange(ctx.Context(), startOfRange, endBreakdownDate, flockIDs) + if err != nil { + return nil, nil, err + } + recWeightByFlock, err := s.HppPerFarmRepo.SumRecordingEggWeightByFlock(ctx.Context(), startOfRange, endExclusive, flockIDs) + if err != nil { + return nil, nil, err + } + doWeightByFlock, err := s.HppPerFarmRepo.SumMarketingDoTelurWeightByFlock(ctx.Context(), startOfRange, endExclusive, flockIDs) + if err != nil { + return nil, nil, err + } + docByFlock, err := s.HppPerFarmRepo.GetDocCostByFlock(ctx.Context(), flockIDs) + if err != nil { + return nil, nil, err + } + + type hppPerFarmAggregate struct { + locationID uint + locationName string + totalCost float64 + feed float64 + ovk float64 + bop float64 + depreciation float64 + other float64 + recWeight float64 + doWeight float64 + docCost float64 + docQty float64 + flocks []dto.HppPerFarmFlockDTO + } + + farmOrder := make([]uint, 0) + farms := make(map[uint]*hppPerFarmAggregate) + + for _, flock := range flockRows { + flockID := flock.ProjectFlockID + + codeTotals, err := s.hppPerFarmFlockCostRange(ctx.Context(), flockID, startBreakdownDate, endBreakdownDate) + if err != nil { + return nil, nil, err + } + + feed := codeTotals[hppPerFarmComponentPakan] + ovk := codeTotals[hppPerFarmComponentOvk] + bop := codeTotals[hppPerFarmComponentBopRegular] + codeTotals[hppPerFarmComponentBopEkspedisi] + nonDepreciation := 0.0 + for _, value := range codeTotals { + nonDepreciation += value + } + other := nonDepreciation - feed - ovk - bop + depreciation := depByFlock[flockID] + totalCost := nonDepreciation + depreciation + + recWeight := recWeightByFlock[flockID] + doWeight := doWeightByFlock[flockID] + + averageDocPrice := int64(0) + if doc, ok := docByFlock[flockID]; ok && doc.DocQty > 0 { + averageDocPrice = int64(math.Round(doc.DocCost / doc.DocQty)) + } + + flockDTO := dto.HppPerFarmFlockDTO{ + ProjectFlockID: int64(flockID), + FlockName: flock.FlockName, + TotalCostRp: totalCost, + FeedCostRp: feed, + OvkCostRp: ovk, + BopCostRp: bop, + DepreciationRp: depreciation, + OtherCostRp: other, + EggWeightRecordingKg: recWeight, + EggWeightDoKg: doWeight, + HppPerKgProduction: hppPerFarmSafeDiv(totalCost, recWeight), + HppPerKgSales: hppPerFarmSafeDiv(totalCost, doWeight), + AverageDocPriceRp: averageDocPrice, + } + + farm, ok := farms[flock.LocationID] + if !ok { + farm = &hppPerFarmAggregate{ + locationID: flock.LocationID, + locationName: flock.LocationName, + flocks: make([]dto.HppPerFarmFlockDTO, 0, 1), + } + farms[flock.LocationID] = farm + farmOrder = append(farmOrder, flock.LocationID) + } + farm.flocks = append(farm.flocks, flockDTO) + farm.totalCost += totalCost + farm.feed += feed + farm.ovk += ovk + farm.bop += bop + farm.depreciation += depreciation + farm.other += other + farm.recWeight += recWeight + farm.doWeight += doWeight + if doc, ok := docByFlock[flockID]; ok { + farm.docCost += doc.DocCost + farm.docQty += doc.DocQty + } + } + + rows := make([]dto.HppPerFarmRowDTO, 0, len(farmOrder)) + summary := dto.HppPerFarmSummaryDTO{} + for _, locID := range farmOrder { + farm := farms[locID] + averageDocPrice := int64(0) + if farm.docQty > 0 { + averageDocPrice = int64(math.Round(farm.docCost / farm.docQty)) + } + rows = append(rows, dto.HppPerFarmRowDTO{ + Location: dto.HppPerKandangLocationDTO{ID: int64(farm.locationID), Name: farm.locationName}, + TotalCostRp: farm.totalCost, + FeedCostRp: farm.feed, + OvkCostRp: farm.ovk, + BopCostRp: farm.bop, + DepreciationRp: farm.depreciation, + OtherCostRp: farm.other, + EggWeightRecordingKg: farm.recWeight, + EggWeightDoKg: farm.doWeight, + HppPerKgProduction: hppPerFarmSafeDiv(farm.totalCost, farm.recWeight), + HppPerKgSales: hppPerFarmSafeDiv(farm.totalCost, farm.doWeight), + AverageDocPriceRp: averageDocPrice, + Flocks: farm.flocks, + }) + summary.TotalCostRp += farm.totalCost + summary.TotalEggWeightRecordingKg += farm.recWeight + summary.TotalEggWeightDoKg += farm.doWeight + } + summary.AverageHppPerKgProduction = hppPerFarmSafeDiv(summary.TotalCostRp, summary.TotalEggWeightRecordingKg) + summary.AverageHppPerKgSales = hppPerFarmSafeDiv(summary.TotalCostRp, summary.TotalEggWeightDoKg) + + totalResults := int64(len(rows)) + totalPages := int64(1) + if totalResults > 0 { + totalPages = int64(math.Ceil(float64(totalResults) / float64(limit))) + } + + offset := (params.Page - 1) * limit + if offset < 0 { + offset = 0 + } + if offset > len(rows) { + offset = len(rows) + } + end := offset + limit + if end > len(rows) { + end = len(rows) + } + + meta := &dto.HppPerFarmMetaDTO{ + Page: params.Page, + Limit: limit, + TotalPages: totalPages, + TotalResults: totalResults, + Filters: filters, + } + data := &dto.HppPerFarmResponseData{ + StartDate: params.StartDate, + EndDate: params.EndDate, + Rows: rows[offset:end], + Summary: summary, + } + return data, meta, nil +} + +// hppPerFarmFlockCostRange returns the range-scoped production cost per component +// code for a project flock, EXCLUDING depreciation (which is summed separately +// from daily snapshots). Each non-depreciation production component is cumulative +// up to a date in the HPP v2 engine, so the range value is the difference between +// the cumulative breakdown at end and at the day before the range start. +func (s *repportService) hppPerFarmFlockCostRange(ctx context.Context, projectFlockID uint, startBreakdownDate, endBreakdownDate time.Time) (map[string]float64, error) { + if s.HppCostRepo == nil { + return nil, errors.New("hpp cost repository is not configured") + } + if s.HppV2Svc == nil { + return nil, errors.New("hpp v2 service is not configured") + } + + codeTotals := make(map[string]float64) + pfkIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, projectFlockID) + if err != nil { + return nil, err + } + + for _, pfkID := range pfkIDs { + endBreakdown, err := s.HppV2Svc.CalculateHppBreakdown(pfkID, &endBreakdownDate) + if err != nil { + return nil, err + } + startBreakdown, err := s.HppV2Svc.CalculateHppBreakdown(pfkID, &startBreakdownDate) + if err != nil { + return nil, err + } + + endMap := hppPerFarmProductionScopeTotalsByCode(endBreakdown) + startMap := hppPerFarmProductionScopeTotalsByCode(startBreakdown) + + seen := make(map[string]bool, len(endMap)+len(startMap)) + for code := range endMap { + seen[code] = true + } + for code := range startMap { + seen[code] = true + } + for code := range seen { + if code == hppPerFarmComponentDepreciation { + continue + } + codeTotals[code] += endMap[code] - startMap[code] + } + } + + return codeTotals, nil +} + +// sumHppPerFarmDepreciationOverRange sums the daily depreciation_value from +// farm_depreciation_snapshots across [startDate, endDate] per project flock, +// computing (and persisting) any missing daily snapshot on demand — same lazy +// compute path the single-day depreciation report uses. +func (s *repportService) sumHppPerFarmDepreciationOverRange(ctx context.Context, startDate, endDate time.Time, projectFlockIDs []uint) (map[uint]float64, error) { + acc := make(map[uint]float64, len(projectFlockIDs)) + if len(projectFlockIDs) == 0 { + return acc, nil + } + if s.ExpenseDepreciationRepo == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured") + } + + for day := startDate; !day.After(endDate); day = day.AddDate(0, 0, 1) { + snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx, day, projectFlockIDs) + if err != nil { + return nil, err + } + byID := make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots)) + for _, snapshot := range snapshots { + byID[snapshot.ProjectFlockId] = snapshot + } + + missing := make([]uint, 0) + for _, id := range projectFlockIDs { + if _, ok := byID[id]; !ok { + missing = append(missing, id) + } + } + if len(missing) > 0 { + computed, err := s.computeExpenseDepreciationSnapshots(ctx, day, missing, nil) + if err != nil { + return nil, err + } + if len(computed) > 0 { + if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx, computed); err != nil { + return nil, err + } + for _, snapshot := range computed { + byID[snapshot.ProjectFlockId] = snapshot + } + } + } + + for id, snapshot := range byID { + acc[id] += snapshot.DepreciationValue + } + } + + return acc, nil +} + +func hppPerFarmProductionScopeTotalsByCode(breakdown *approvalService.HppV2Breakdown) map[string]float64 { + out := make(map[string]float64) + if breakdown == nil { + return out + } + for i := range breakdown.Components { + comp := &breakdown.Components[i] + out[comp.Code] += hppPerFarmProductionScopeTotal(comp) + } + return out +} + +// hppPerFarmProductionScopeTotal mirrors the engine's componentScopeTotal for the +// production_cost scope (that helper is unexported in the common service package). +func hppPerFarmProductionScopeTotal(component *approvalService.HppV2Component) float64 { + if component == nil { + return 0 + } + total := 0.0 + hasPartScopes := false + for i := range component.Parts { + part := &component.Parts[i] + if len(part.Scopes) == 0 { + continue + } + hasPartScopes = true + for _, scope := range part.Scopes { + if scope == hppPerFarmProductionScope { + total += part.Total + break + } + } + } + if hasPartScopes { + return total + } + for _, scope := range component.Scopes { + if scope == hppPerFarmProductionScope { + return component.Total + } + } + return 0 +} + +func hppPerFarmSafeDiv(numerator, denominator float64) float64 { + if denominator <= 0 { + return 0 + } + value := numerator / denominator + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0 + } + return value +} + +func (s *repportService) parseHppPerFarmQuery(ctx *fiber.Ctx) (*validation.HppPerFarmQuery, dto.HppPerFarmFiltersDTO, 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", "") + startDate := ctx.Query("start_date", "") + endDate := ctx.Query("end_date", "") + + areaIDs, err := parseCommaSeparatedInt64s(rawArea) + if err != nil { + return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + locationIDs, err := parseCommaSeparatedInt64s(rawLocation) + if err != nil { + return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.HppPerFarmFiltersDTO{}, err + } + areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.HppPerFarmFiltersDTO{}, err + } + if locationScope.Restrict { + allowed := toInt64Slice(locationScope.IDs) + if len(allowed) == 0 { + locationIDs = []int64{-1} + } else if len(locationIDs) > 0 { + locationIDs = intersectInt64(locationIDs, allowed) + } else { + locationIDs = allowed + } + } + if areaScope.Restrict { + allowed := toInt64Slice(areaScope.IDs) + if len(allowed) == 0 { + areaIDs = []int64{-1} + } else if len(areaIDs) > 0 { + areaIDs = intersectInt64(areaIDs, allowed) + } else { + areaIDs = allowed + } + } + + params := &validation.HppPerFarmQuery{ + Page: page, + Limit: limit, + StartDate: startDate, + EndDate: endDate, + AreaIDs: areaIDs, + LocationIDs: locationIDs, + } + filters := dto.NewHppPerFarmFiltersDTO(rawArea, rawLocation, startDate, endDate) + return params, filters, nil +} + func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validation.ExpenseDepreciationQuery, dto.ExpenseDepreciationFiltersDTO, error) { page := ctx.QueryInt("page", 1) if page < 1 { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index c2d06c12..25200bb9 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -78,6 +78,15 @@ type HppPerKandangQuery struct { WeightMax *float64 `query:"-"` } +type HppPerFarmQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` + StartDate string `query:"start_date" validate:"required,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"required,datetime=2006-01-02"` + AreaIDs []int64 `query:"-"` + LocationIDs []int64 `query:"-"` +} + type HppV2BreakdownQuery struct { ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"required,gt=0"` Period string `query:"period" validate:"required,datetime=2006-01-02"` From 255e6a16d3820ef482ecbdc6fb49f65542ba79c0 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 3 Jun 2026 09:43:34 +0700 Subject: [PATCH 2/2] add validate query param --- internal/modules/repports/services/repport.service.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 1d158e92..213f7643 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -3373,6 +3373,16 @@ func (s *repportService) parseHppPerFarmQuery(ctx *fiber.Ctx) (*validation.HppPe startDate := ctx.Query("start_date", "") endDate := ctx.Query("end_date", "") + if strings.TrimSpace(startDate) == "" { + return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "start_date is required") + } + if strings.TrimSpace(endDate) == "" { + return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "end_date is required") + } + if strings.TrimSpace(rawLocation) == "" { + return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "location_id is required") + } + areaIDs, err := parseCommaSeparatedInt64s(rawArea) if err != nil { return nil, dto.HppPerFarmFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())