diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 3e64f89b..ed3cfcbc 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -14,14 +14,16 @@ import ( ) type ClosingController struct { - ClosingService service.ClosingService - SapronakService service.SapronakService + ClosingService service.ClosingService + SapronakService service.SapronakService + ClosingKeuanganService service.ClosingKeuanganService } -func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService) *ClosingController { +func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService, closingKeuanganService service.ClosingKeuanganService) *ClosingController { return &ClosingController{ - ClosingService: closingService, - SapronakService: sapronakService, + ClosingService: closingService, + SapronakService: sapronakService, + ClosingKeuanganService: closingKeuanganService, } } @@ -338,7 +340,7 @@ func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") } - result, err := u.ClosingService.GetClosingKeuangan(c, uint(projectFlockID)) + result, err := u.ClosingKeuanganService.GetClosingKeuangan(c, uint(projectFlockID)) if err != nil { return err } @@ -352,6 +354,34 @@ func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetClosingKeuanganByKandang(c *fiber.Ctx) error { + projectParam := c.Params("project_flock_id") + kandangParam := c.Params("project_flock_kandang_id") + + projectFlockID, err := strconv.Atoi(projectParam) + if err != nil || projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + + pfkID, err := strconv.Atoi(kandangParam) + if err != nil || pfkID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + + result, err := u.ClosingKeuanganService.GetClosingKeuanganByKandang(c, uint(projectFlockID), uint(pfkID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get closing keuangan by kandang successfully", + Data: result, + }) +} + func (u *ClosingController) GetExpeditionHPP(c *fiber.Ctx) error { param := c.Params("project_flock_id") diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go index fa99a59d..6ca19d5c 100644 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ b/internal/modules/closings/dto/closingKeuangan.dto.go @@ -1,135 +1,103 @@ package dto -import ( - "slices" - "strings" +// === CLOSING KEUANGAN CODES === - "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) +// Closing HPP Codes +type ClosingHPPCode string -// === CONSTANTS === const ( - HPPGroupPengeluaran = "HPP dan Pengeluaran" - HPPGroupBahanBaku = "HPP dan Bahan Baku" - HPPLabelOverhead = "Pengeluaran Overhead" - HPPLabelEkspedisi = "Beban Ekspedisi" - HPPSummaryLabel = "HPP" - - PLSalesTypeChicken = "Penjualan Ayam Besar" - PLSalesTypeEgg = "Penjualan Telur" - - PLItemTypeSapronak = "Pembelian Sapronak" - PLItemTypeOverhead = "Pengeluaran Overhead" - PLItemTypeEkspedisi = "Beban Ekspedisi" - - PLSummaryLabelGrossProfit = "LABA RUGI BRUTTO" - PLSummaryLabelSubTotal = "SUB TOTAL" - PLSummaryLabelNetProfit = "LABA RUGI NETTO" - - PurchaseLabelPrefix = "Pembelian " + HPPCodePakan ClosingHPPCode = "PAKAN" + HPPCodeOVK ClosingHPPCode = "OVK" + HPPCodeDOC ClosingHPPCode = "DOC" + HPPCodeDepresiasi ClosingHPPCode = "DEPRESIASI" + HPPCodeOverhead ClosingHPPCode = "OVERHEAD" + HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI" ) -// === CONTEXT STRUCTS === +// Closing Profit Loss Codes +type ClosingProfitLossCode string -type CalculationContext struct { - TotalPopulation float64 - TotalWeightProduced float64 - TotalEggWeightKg float64 - TotalDepletion float64 - TotalWeightSold float64 - ActualPopulation float64 -} +const ( + PLCodeSales ClosingProfitLossCode = "SALES" + PLCodeSapronak ClosingProfitLossCode = "SAPRONAK" + PLCodeOverhead ClosingProfitLossCode = "OVERHEAD" + PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI" +) -type ClosingKeuanganInput struct { - ProjectFlockCategory string - PurchaseItems []entities.PurchaseItem - Budgets []entities.ProjectBudget - Realizations []entities.ExpenseRealization - DeliveryProducts []entities.MarketingDeliveryProduct - Chickins []entities.ProjectChickin - TotalWeightProduced float64 - TotalEggWeightKg float64 - TotalDepletion float64 -} - -// === BASE METRICS === +// === NEW CLOSING KEUANGAN DTO === +// FinancialMetrics represents financial metrics with per unit and total amounts type FinancialMetrics struct { RpPerBird float64 `json:"rp_per_bird"` RpPerKg float64 `json:"rp_per_kg"` Amount float64 `json:"amount"` } -type Comparison struct { +// HPPItem represents an item in HPP section +type HPPItem struct { + ID uint `json:"id"` + Category string `json:"category"` // "purchase" or "overhead" + Code string `json:"code"` // "PAKAN", "OVK", "DOC", "EKSPEDISI" + Label string `json:"label"` Budgeting FinancialMetrics `json:"budgeting"` Realization FinancialMetrics `json:"realization"` } -// === HPP PURCHASES PACKAGE === - -type HppItem struct { - Type string `json:"type"` - Comparison +// HPPSummary represents summary for HPP section +type HPPSummary struct { + Label string `json:"label"` + Budgeting FinancialMetrics `json:"budgeting"` + Realization FinancialMetrics `json:"realization"` + EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` + EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` } -type HppGroup struct { - GroupName string `json:"group_name"` - Data []HppItem `json:"data"` +// HPPSection represents HPP data section +type HPPSection struct { + Items []HPPItem `json:"items"` + Summary HPPSummary `json:"summary"` } -type SummaryHpp struct { - Label string `json:"label"` - Budgeting FinancialMetrics `json:"budgeting"` - Realization FinancialMetrics `json:"realization"` - EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` - EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` +// ProfitLossItem represents an item in Profit & Loss section +type ProfitLossItem struct { + Code string `json:"code"` // "SALES", "PURCHASE_DOC", "OVERHEAD", "EKSPEDISI" + Label string `json:"label"` + Type string `json:"type"` // "income", "purchase", "overhead" + RpPerBird float64 `json:"rp_per_bird"` + RpPerKg float64 `json:"rp_per_kg"` + Amount float64 `json:"amount"` } -type HppPurchasesSection struct { - Hpp []HppGroup `json:"hpp"` - SummaryHpp SummaryHpp `json:"summary_hpp"` -} - -// === PROFIT LOSS PACKAGE === - -type PLItem struct { - Type string `json:"type"` - FinancialMetrics -} - -type PLSummaryItem struct { - Label string `json:"label"` - FinancialMetrics -} - -type PLSummaryGroup struct { - GrossProfit PLSummaryItem `json:"gross_profit"` - SubTotal PLSummaryItem `json:"sub_total"` - NetProfit PLSummaryItem `json:"net_profit"` -} - -type ProfitLossData struct { - Penjualan []PLItem `json:"penjualan"` - Pembelian []PLItem `json:"pembelian"` - Overhead PLItem `json:"overhead"` - Ekspedisi PLItem `json:"ekspedisi"` - Summary PLSummaryGroup `json:"summary"` +// ProfitLossSummary represents summary for Profit & Loss section +type ProfitLossSummary struct { + GrossProfit FinancialMetrics `json:"gross_profit"` + SubTotal FinancialMetrics `json:"sub_total"` + NetProfit FinancialMetrics `json:"net_profit"` } +// ProfitLossSection represents Profit & Loss data section type ProfitLossSection struct { - Data ProfitLossData `json:"data"` + Items []ProfitLossItem `json:"items"` + Summary ProfitLossSummary `json:"summary"` } -// === RESPONSE DTO (ROOT) === +// ClosingKeuanganData represents the main data structure +type ClosingKeuanganData struct { + HPP HPPSection `json:"hpp"` + ProfitLoss ProfitLossSection `json:"profit_loss"` +} -type ReportResponse struct { - HppPurchases HppPurchasesSection `json:"hpp_purchases"` - ProfitLoss ProfitLossSection `json:"profit_loss"` +// ClosingKeuanganResponse represents the full API response +type ClosingKeuanganResponse struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Data ClosingKeuanganData `json:"data"` } // === MAPPER FUNCTIONS === +// ToFinancialMetrics creates FinancialMetrics from values func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { return FinancialMetrics{ RpPerBird: rpPerBird, @@ -138,451 +106,80 @@ func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { } } -func ToComparison(budgeting, realization FinancialMetrics) Comparison { - return Comparison{ +// ToHPPItem creates HPP item +func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem { + return HPPItem{ + ID: id, + Category: category, + Code: code, + Label: label, Budgeting: budgeting, Realization: realization, } } -// === HPP PENGELUARAN (from Purchase Items) === - -func getFlagLabel(flagType utils.FlagType) string { - return PurchaseLabelPrefix + string(flagType) -} - -func buildHppItemsByPurchaseFlags(purchaseItems []entities.PurchaseItem, ctx CalculationContext) []HppItem { - flags := []utils.FlagType{ - utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, utils.FlagPakan, - utils.FlagPreStarter, utils.FlagStarter, utils.FlagFinisher, - utils.FlagOVK, utils.FlagObat, utils.FlagVitamin, utils.FlagKimia, - } - - items := []HppItem{} - seenFlags := make(map[utils.FlagType]bool) - - for _, item := range purchaseItems { - if item.Product == nil || len(item.Product.Flags) == 0 { - continue - } - - for _, flag := range item.Product.Flags { - flagType := utils.FlagType(flag.Name) - - if slices.Contains(flags, flagType) && !seenFlags[flagType] { - amount := sumPurchasesByFlag(purchaseItems, flagType) - rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.TotalPopulation, ctx.TotalWeightProduced) - - items = append(items, HppItem{ - Type: getFlagLabel(flagType), - Comparison: ToComparison( - ToFinancialMetrics(rpPerBird, rpPerKg, amount), - ToFinancialMetrics(rpPerBird, rpPerKg, amount), - ), - }) - seenFlags[flagType] = true - } - } - } - - return items -} - -// === HPP BAHAN BAKU (from ProjectBudget + ExpenseRealization) === - -func createHppOverheadItem(budgetAmount, realizationAmount float64, ctx CalculationContext) HppItem { - budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(budgetAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) - realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(realizationAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) - - return HppItem{ - Type: HPPLabelOverhead, - Comparison: ToComparison( - ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount), - ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount), - ), +// ToHPPSummary creates HPP summary +func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary { + return HPPSummary{ + Label: label, + Budgeting: budgeting, + Realization: realization, + EggBudgeting: eggBudgeting, + EggRealization: eggRealization, } } -func createHppEkspedisiItem(ekspedisiAmount float64, ctx CalculationContext) HppItem { - ekspedisiRpPerBird, ekspedisiRpPerKg := calculatePerUnitMetrics(ekspedisiAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) - - return HppItem{ - Type: HPPLabelEkspedisi, - Comparison: ToComparison( - ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount), - ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount), - ), +// ToHPPSection creates HPP section +func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection { + return HPPSection{ + Items: items, + Summary: summary, } } -func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppGroup { - items := []HppItem{} - - budgetAmount := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) - realizationAmount := getOperationalExpenses(realizations) - - if budgetAmount > 0 || realizationAmount > 0 { - items = append(items, createHppOverheadItem(budgetAmount, realizationAmount, ctx)) - } - - ekspedisiAmount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi)) - items = append(items, createHppEkspedisiItem(ekspedisiAmount, ctx)) - - return HppGroup{ - GroupName: HPPGroupBahanBaku, - Data: items, +// ToProfitLossItem creates Profit & Loss item +func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem { + return ProfitLossItem{ + Code: code, + Label: label, + Type: itemType, + RpPerBird: rpPerBird, + RpPerKg: rpPerKg, + Amount: amount, } } -// === HPP SUMMARY === - -func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp { - purchaseTotal := sumPurchaseTotal(purchaseItems) - budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) - totalBudget := purchaseTotal + budgetTotal - - totalRealization := sumRealizationsByFilter(realizations, func(*entities.ExpenseRealization) bool { return true }) - - budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced) - realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) - - summary := SummaryHpp{ - Label: label, - Budgeting: ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), - Realization: ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), - } - - if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 { - budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg) - realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg) - - summary.EggBudgeting = &FinancialMetrics{ - RpPerBird: 0, - RpPerKg: budgetEggRpPerKg, - Amount: totalBudget, - } - summary.EggRealization = &FinancialMetrics{ - RpPerBird: 0, - RpPerKg: realizationEggRpPerKg, - Amount: totalRealization, - } - } - - return summary -} - -func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection { - hppGroups := []HppGroup{ - { - GroupName: HPPGroupPengeluaran, - Data: buildHppItemsByPurchaseFlags(purchaseItems, ctx), - }, - ToHppBahanBakuGroup(budgets, realizations, ctx), - } - - summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx) - - return HppPurchasesSection{ - Hpp: hppGroups, - SummaryHpp: summaryHpp, +// ToProfitLossSummary creates Profit & Loss summary +func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary { + return ProfitLossSummary{ + GrossProfit: grossProfit, + SubTotal: subTotal, + NetProfit: netProfit, } } -// === PROFIT & LOSS === - -func ToPLItem(itemType string, metrics FinancialMetrics) PLItem { - return PLItem{ - Type: itemType, - FinancialMetrics: metrics, - } -} - -func ToPLSummaryItem(label string, metrics FinancialMetrics) PLSummaryItem { - return PLSummaryItem{ - Label: label, - FinancialMetrics: metrics, - } -} - -func createPLItemWithMetrics(itemType string, amount float64, ctx CalculationContext) PLItem { - rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightProduced) - return ToPLItem(itemType, ToFinancialMetrics(rpPerBird, rpPerKg, amount)) -} - -func sumPLItems(items []PLItem) (totalAmount, totalPerBird float64) { - for _, item := range items { - totalAmount += item.Amount - totalPerBird += item.RpPerBird - } - return -} - -func createPenjualanItem(salesType string, amount float64, ctx CalculationContext) PLItem { - rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightSold) - return ToPLItem(salesType, ToFinancialMetrics(rpPerBird, rpPerKg, amount)) -} - -func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.MarketingDeliveryProduct, ctx CalculationContext) []PLItem { - items := []PLItem{} - - categorized := categorizeDeliveriesBySalesType(deliveryProducts) - - if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) { - ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken]) - telurAmount := sumDeliveriesByCategory(categorized[PLSalesTypeEgg]) - - items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx)) - items = append(items, createPenjualanItem(PLSalesTypeEgg, telurAmount, ctx)) - } else { - ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken]) - items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx)) - } - - return items -} - -func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { - purchaseAmount := sumPurchaseTotal(purchases) - - return []PLItem{ - createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx), - } -} - -func ToOverheadItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { - realizationAmount := getOperationalExpenses(realizations) - return []PLItem{ - createPLItemWithMetrics(PLItemTypeOverhead, realizationAmount, ctx), - } -} - -func ToEkspedisiItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { - amount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi)) - return []PLItem{ - createPLItemWithMetrics(PLItemTypeEkspedisi, amount, ctx), - } -} - -func ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) PLSummaryGroup { - totalPenjualan, totalPenjualanPerBird := sumPLItems(penjualanItems) - totalPembelian, totalPembelianPerBird := sumPLItems(pembelianItems) - totalOverhead, totalOverheadPerBird := sumPLItems(overheadItems) - totalEkspedisi, totalEkspedisiPerBird := sumPLItems(ekspedisiItems) - - grossProfit := totalPenjualan - totalPembelian - grossProfitPerBird := totalPenjualanPerBird - totalPembelianPerBird - - totalOtherExpenses := totalOverhead + totalEkspedisi - totalOtherExpensesPerBird := totalOverheadPerBird + totalEkspedisiPerBird - - netProfit := grossProfit - totalOtherExpenses - netProfitPerBird := grossProfitPerBird - totalOtherExpensesPerBird - - return PLSummaryGroup{ - GrossProfit: ToPLSummaryItem(PLSummaryLabelGrossProfit, ToFinancialMetrics(grossProfitPerBird, 0, grossProfit)), - SubTotal: ToPLSummaryItem(PLSummaryLabelSubTotal, ToFinancialMetrics(totalOtherExpensesPerBird, 0, totalOtherExpenses)), - NetProfit: ToPLSummaryItem(PLSummaryLabelNetProfit, ToFinancialMetrics(netProfitPerBird, 0, netProfit)), - } -} - -func ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossData { - summary := ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems) - - totalOverhead := aggregatePLItems(overheadItems, PLItemTypeOverhead) - totalEkspedisi := aggregatePLItems(ekspedisiItems, PLItemTypeEkspedisi) - - return ProfitLossData{ - Penjualan: penjualanItems, - Pembelian: pembelianItems, - Overhead: totalOverhead, - Ekspedisi: totalEkspedisi, - Summary: summary, - } -} - -func ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossSection { +// ToProfitLossSection creates Profit & Loss section +func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection { return ProfitLossSection{ - Data: ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems), + Items: items, + Summary: summary, } } -func aggregatePLItems(items []PLItem, label string) PLItem { - totalAmount, totalPerBird := sumPLItems(items) - return ToPLItem(label, ToFinancialMetrics(totalPerBird, 0, totalAmount)) -} - -func ToReportResponse(hppPurchases HppPurchasesSection, profitLoss ProfitLossSection) ReportResponse { - return ReportResponse{ - HppPurchases: hppPurchases, - ProfitLoss: profitLoss, +// ToClosingKeuanganData creates complete closing keuangan data +func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData { + return ClosingKeuanganData{ + HPP: hpp, + ProfitLoss: profitLoss, } } -func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse { - var totalPopulation float64 - var totalWeightSold float64 - - for _, chickin := range input.Chickins { - totalPopulation += chickin.UsageQty - } - - for _, delivery := range input.DeliveryProducts { - totalWeightSold += delivery.TotalWeight - } - - ctx := CalculationContext{ - TotalPopulation: totalPopulation, - TotalWeightProduced: input.TotalWeightProduced, - TotalEggWeightKg: input.TotalEggWeightKg, - TotalDepletion: input.TotalDepletion, - TotalWeightSold: totalWeightSold, - ActualPopulation: totalPopulation - input.TotalDepletion, - } - - hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx) - penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx) - pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx) - overheadItems := ToOverheadItems(input.Realizations, ctx) - ekspedisiItems := ToEkspedisiItems(input.Realizations, ctx) - plSection := ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems) - - return ToReportResponse(hppSection, plSection) -} - -// === HELPER FUNCTIONS === - -func calculatePerUnitMetrics(amount, totalPopulation, totalWeightSold float64) (rpPerBird, rpPerKg float64) { - if totalPopulation > 0 { - rpPerBird = amount / totalPopulation - } - if totalWeightSold > 0 { - rpPerKg = amount / totalWeightSold - } - return rpPerBird, rpPerKg -} - -func hasProductFlag(flags []entities.Flag, flagType utils.FlagType) bool { - for _, flag := range flags { - if strings.ToUpper(flag.Name) == string(flagType) { - return true - } - } - return false -} - -func filterByPurchaseFlag(flagType utils.FlagType) func(*entities.PurchaseItem) bool { - return func(item *entities.PurchaseItem) bool { - if item.Product == nil || len(item.Product.Flags) == 0 { - return false - } - return hasProductFlag(item.Product.Flags, flagType) +// ToSuccessClosingKeuanganResponse creates success response +func ToSuccessClosingKeuanganResponse(data ClosingKeuanganData) ClosingKeuanganResponse { + return ClosingKeuanganResponse{ + Code: 200, + Status: "success", + Message: "Get closing keuangan successfully", + Data: data, } } - -func filterRealizationByNonstockFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool { - return func(realization *entities.ExpenseRealization) bool { - if realization.ExpenseNonstock == nil || realization.ExpenseNonstock.Nonstock == nil { - return false - } - return hasProductFlag(realization.ExpenseNonstock.Nonstock.Flags, flagType) - } -} - -func filterRealizationExceptFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool { - hasFlag := filterRealizationByNonstockFlag(flagType) - return func(realization *entities.ExpenseRealization) bool { - return !hasFlag(realization) - } -} - -func sumByFilter[T any](items []T, extractor func(*T) float64, filter func(*T) bool) float64 { - amount := 0.0 - for i := range items { - if filter(&items[i]) { - amount += extractor(&items[i]) - } - } - return amount -} - -func sumPurchasesByFilter(purchases []entities.PurchaseItem, filter func(*entities.PurchaseItem) bool) float64 { - return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, filter) -} - -func sumPurchasesByFlag(purchases []entities.PurchaseItem, flagType utils.FlagType) float64 { - return sumPurchasesByFilter(purchases, filterByPurchaseFlag(flagType)) -} - -func sumPurchaseTotal(purchases []entities.PurchaseItem) float64 { - return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, func(*entities.PurchaseItem) bool { return true }) -} - -func sumBudgetsByFilter(budgets []entities.ProjectBudget, filter func(*entities.ProjectBudget) bool) float64 { - return sumByFilter(budgets, func(b *entities.ProjectBudget) float64 { return b.Price * b.Qty }, filter) -} - -func sumRealizationsByFilter(realizations []entities.ExpenseRealization, filter func(*entities.ExpenseRealization) bool) float64 { - return sumByFilter(realizations, func(r *entities.ExpenseRealization) float64 { return r.Price * r.Qty }, filter) -} - -func getOperationalExpenses(realizations []entities.ExpenseRealization) float64 { - return sumRealizationsByFilter(realizations, filterRealizationExceptFlag(utils.FlagEkspedisi)) -} - -func isChickenProductFlag(flagType utils.FlagType) bool { - switch flagType { - case utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, - utils.FlagAyamAfkir, utils.FlagAyamCulling, utils.FlagAyamMati: - return true - } - return false -} - -func isEggProductFlag(flagType utils.FlagType) bool { - switch flagType { - case utils.FlagTelur, utils.FlagTelurUtuh, utils.FlagTelurPecah, - utils.FlagTelurPutih, utils.FlagTelurRetak: - return true - } - return false -} - -func getSalesTypeFromProductFlags(product *entities.Product) string { - if product == nil || len(product.Flags) == 0 { - return PLSalesTypeChicken - } - - for _, flag := range product.Flags { - flagType := utils.FlagType(strings.ToUpper(flag.Name)) - - if isEggProductFlag(flagType) { - return PLSalesTypeEgg - } - if isChickenProductFlag(flagType) { - return PLSalesTypeChicken - } - } - - return PLSalesTypeChicken -} - -func categorizeDeliveriesBySalesType(deliveries []entities.MarketingDeliveryProduct) map[string][]entities.MarketingDeliveryProduct { - categorized := make(map[string][]entities.MarketingDeliveryProduct) - - for _, delivery := range deliveries { - product := delivery.MarketingProduct.ProductWarehouse.Product - salesType := getSalesTypeFromProductFlags(&product) - - categorized[salesType] = append(categorized[salesType], delivery) - } - - return categorized -} - -func sumDeliveriesByCategory(deliveries []entities.MarketingDeliveryProduct) float64 { - amount := 0.0 - for _, delivery := range deliveries { - amount += delivery.TotalPrice - } - return amount -} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index a79c9f0b..1079663d 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -25,6 +25,7 @@ type ClosingModule struct{} func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { closingRepo := rClosing.NewClosingRepository(db) + closingKeuanganRepo := rClosing.NewClosingKeuanganRepository(db) userRepo := rUser.NewUserRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) @@ -41,8 +42,9 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalService := commonSvc.NewApprovalService(approvalRepo) closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate) + closingKeuanganService := sClosing.NewClosingKeuanganService(closingKeuanganRepo, projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo) sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) - ClosingRoutes(router, userService, closingService, sapronakService) + ClosingRoutes(router, userService, closingService, sapronakService, closingKeuanganService) } diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 7ecc86d8..582a1207 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -33,7 +33,6 @@ type ClosingRepository interface { FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) - GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } @@ -944,132 +943,6 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand return incoming, outgoing, nil } -type ActualUsageCostRow struct { - ProductID uint `gorm:"column:product_id"` - ProductName string `gorm:"column:product_name"` - FlagName string `gorm:"column:flag_name"` - TotalQty float64 `gorm:"column:total_qty"` - TotalPrice float64 `gorm:"column:total_price"` - AveragePrice float64 `gorm:"column:average_price"` -} - -func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) { - if projectFlockID == 0 { - return []ActualUsageCostRow{}, nil - } - - db := r.DB().WithContext(ctx) - - // Get all project flock kandang IDs for this project flock - var pfkIDs []uint - err := db.Table("project_flock_kandangs"). - Where("project_flock_id = ?", projectFlockID). - Pluck("id", &pfkIDs).Error - if err != nil { - return nil, err - } - - if len(pfkIDs) == 0 { - return []ActualUsageCostRow{}, nil - } - - var rows []ActualUsageCostRow - - purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() - transferStockableKey := fifo.StockableKeyStockTransferIn.String() - - recordingQuery := db. - Table("recordings AS r"). - Select(` - pw.product_id AS product_id, - p.name AS product_name, - COALESCE(f.name, tf.name) AS flag_name, - COALESCE(SUM( - CASE - WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) - ELSE 0 - END - ), 0) AS total_qty, - COALESCE(SUM( - CASE - WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0) - ELSE 0 - END - ), 0) AS total_price, - COALESCE(SUM( - CASE - WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) - ELSE 0 - END - ), 0) AS qty_divisor, - COALESCE(SUM( - CASE - WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0) - ELSE 0 - END - ), 0) / NULLIF(COALESCE(SUM( - CASE - WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) - ELSE 0 - END - ), 0), 0) AS average_price`, - purchaseStockableKey, transferStockableKey, - purchaseStockableKey, transferStockableKey, - purchaseStockableKey, transferStockableKey, - purchaseStockableKey, transferStockableKey, - purchaseStockableKey, transferStockableKey). - Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). - Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN products AS p ON p.id = pw.product_id"). - Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", - "recording_stocks", 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 ?", pfkIDs). - Where("r.deleted_at IS NULL"). - Group("pw.product_id, p.name, COALESCE(f.name, tf.name)") - - if err := recordingQuery.Scan(&rows).Error; err != nil { - return nil, err - } - - chickinQuery := db. - Table("project_chickins AS pc"). - Select(` - pw.product_id AS product_id, - p.name AS product_name, - f.name AS flag_name, - COALESCE(SUM(pc.usage_qty), 0) AS total_qty, - COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS total_price, - COALESCE(AVG(COALESCE(pi.price, 0)), 0) AS average_price - `). - Joins("JOIN product_warehouses AS pw ON pw.id = pc.product_warehouse_id"). - Joins("JOIN products AS p ON p.id = pw.product_id"). - Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). - Joins("LEFT JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Where("pc.project_flock_kandang_id IN ?", pfkIDs). - Where("pc.usage_qty > 0"). - Group("pw.product_id, p.name, f.name") - - var chickinRows []ActualUsageCostRow - if err := chickinQuery.Scan(&chickinRows).Error; err != nil { - return nil, err - } - - rows = append(rows, chickinRows...) - - return rows, nil -} - func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { if len(productIDs) == 0 { return []entity.Product{}, nil diff --git a/internal/modules/closings/repositories/closingKeuangan.repository.go b/internal/modules/closings/repositories/closingKeuangan.repository.go new file mode 100644 index 00000000..dedea807 --- /dev/null +++ b/internal/modules/closings/repositories/closingKeuangan.repository.go @@ -0,0 +1,365 @@ +package repository + +import ( + "context" + "fmt" + "sort" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +// ClosingKeuanganRepository handles database operations for closing keuangan +type ClosingKeuanganRepository interface { + repository.BaseRepository[interface{}] + + // All Product Usage + GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) + + // Depletion per kandang + GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + + // Weight produced from uniformity per kandang + GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + + // DB returns the underlying GORM DB instance + DB() *gorm.DB +} + +type ClosingKeuanganRepositoryImpl struct { + *repository.BaseRepositoryImpl[interface{}] +} + +func NewClosingKeuanganRepository(db *gorm.DB) ClosingKeuanganRepository { + return &ClosingKeuanganRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[interface{}](db), + } +} + +// Result Rows + +type ProductUsageRow struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + FlagNames string `gorm:"column:flag_names"` + TotalQty float64 `gorm:"column:total_qty"` + Price float64 `gorm:"column:price"` + TotalPengeluaran float64 `gorm:"column:total_pengeluaran"` +} + +// GetAllProductUsageByProjectFlockKandangID gets all product usage for a project flock kandang +// Combines data from all usable types: recordings, chickins, marketing, transfers, adjustments +// flagFilters: optional filter to get only specific flags (e.g., ["PAKAN", "OVK"]), empty means get all +func (r *ClosingKeuanganRepositoryImpl) GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) { + if projectFlockKandangID == 0 { + return []ProductUsageRow{}, nil + } + + type SubQueryResult struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + TotalQty float64 `gorm:"column:total_qty"` + Price float64 `gorm:"column:price"` + } + + type AggregatedResult struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + TotalQty float64 `gorm:"column:total_qty"` + Price float64 `gorm:"column:price"` + PriceCount int `gorm:"-"` // For calculating average price + } + + type FlagResult struct { + ProductID uint `gorm:"column:product_id"` + FlagNames string `gorm:"column:flag_names"` + } + + var allResults []SubQueryResult + + // Subquery 1: Recordings + var recordingsResults []SubQueryResult + err := r.DB().WithContext(ctx). + Table("recordings r"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(CASE "+ + "WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(sa.qty, 0) "+ + "WHEN sa.stockable_type = 'STOCK_TRANSFER_IN' THEN COALESCE(std.usage_qty, 0) "+ + "WHEN sa.stockable_type = 'TRANSFERTOLAYING_IN' THEN COALESCE(ltt.total_used, 0) "+ + "WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN COALESCE(adjs.total_used, 0) "+ + "WHEN sa.stockable_type = 'PROJECT_FLOCK_POPULATION' THEN COALESCE(pfp.total_used_qty, 0) "+ + "ELSE 0 END), 0) as total_qty, "+ + "COALESCE(AVG(CASE WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(pi.price, 0) END), 0) as price"). + Joins("JOIN recording_stocks rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN stock_allocations sa ON sa.usable_type = 'RECORDING_STOCK' AND sa.usable_id = rs.id AND sa.status = 'ACTIVE'"). + Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = 'PURCHASE_ITEMS'"). + Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = 'STOCK_TRANSFER_IN'"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = 'TRANSFERTOLAYING_IN'"). + Joins("LEFT JOIN adjustment_stocks adjs ON adjs.id = sa.stockable_id AND sa.stockable_type = 'ADJUSTMENT_IN'"). + Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = 'PROJECT_FLOCK_POPULATION'"). + Where("r.project_flock_kandangs_id = ?", projectFlockKandangID). + Where("r.deleted_at IS NULL"). + Group("pw.product_id, p.name"). + Scan(&recordingsResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get recordings product usage: %w", err) + } + fmt.Printf("[REPO] Recordings query: %d results for projectFlockKandangID=%d\n", len(recordingsResults), projectFlockKandangID) + allResults = append(allResults, recordingsResults...) + + // Subquery 2: Chickins + var chickinsResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("project_chickins pc"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(pc.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN product_warehouses pw ON pw.id = pc.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). + Where("pc.usage_qty > 0"). + Group("pw.product_id, p.name"). + Scan(&chickinsResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get chickins product usage: %w", err) + } + fmt.Printf("[REPO] Chickins query: %d results for projectFlockKandangID=%d\n", len(chickinsResults), projectFlockKandangID) + allResults = append(allResults, chickinsResults...) + + // Subquery 3: Marketing Delivery + var marketingResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("marketing_delivery_products mdp"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(mdp.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Group("pw.product_id, p.name"). + Scan(&marketingResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get marketing product usage: %w", err) + } + fmt.Printf("[REPO] Marketing query: %d results for projectFlockKandangID=%d\n", len(marketingResults), projectFlockKandangID) + allResults = append(allResults, marketingResults...) + + // Subquery 4: Laying Transfer Sources + var layingTransferResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("laying_transfer_sources lts"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(lts.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id"). + Joins("JOIN product_warehouses pw ON pw.id = lts.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Group("pw.product_id, p.name"). + Scan(&layingTransferResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get laying transfer product usage: %w", err) + } + fmt.Printf("[REPO] Laying Transfer query: %d results for projectFlockKandangID=%d\n", len(layingTransferResults), projectFlockKandangID) + allResults = append(allResults, layingTransferResults...) + + // Subquery 5: Stock Transfer Details + var stockTransferResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("stock_transfer_details std"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(std.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN product_warehouses pw ON pw.id = std.source_product_warehouse_id"). + Joins("JOIN products p ON p.id = std.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Group("pw.product_id, p.name"). + Scan(&stockTransferResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get stock transfer product usage: %w", err) + } + fmt.Printf("[REPO] Stock Transfer query: %d results for projectFlockKandangID=%d\n", len(stockTransferResults), projectFlockKandangID) + allResults = append(allResults, stockTransferResults...) + + // Subquery 6: Adjustment Stocks + var adjustmentResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("adjustment_stocks ads"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(ads.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN product_warehouses pw ON pw.id = ads.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where("ads.usage_qty > 0"). + Group("pw.product_id, p.name"). + Scan(&adjustmentResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get adjustment product usage: %w", err) + } + fmt.Printf("[REPO] Adjustment query: %d results for projectFlockKandangID=%d\n", len(adjustmentResults), projectFlockKandangID) + allResults = append(allResults, adjustmentResults...) + + fmt.Printf("[REPO] Total raw results before aggregation: %d items\n", len(allResults)) + + // Aggregate results by product_id + aggregatedMap := make(map[uint]*AggregatedResult) + for _, result := range allResults { + key := result.ProductID + if existing, exists := aggregatedMap[key]; exists { + existing.TotalQty += result.TotalQty + existing.Price += result.Price + existing.PriceCount++ + } else { + aggregatedMap[key] = &AggregatedResult{ + ProductID: result.ProductID, + ProductName: result.ProductName, + TotalQty: result.TotalQty, + Price: result.Price, + PriceCount: 1, + } + } + } + + fmt.Printf("[REPO] Aggregated to %d unique products\n", len(aggregatedMap)) + + // Get flags for all products + productIDs := make([]uint, 0, len(aggregatedMap)) + for id := range aggregatedMap { + productIDs = append(productIDs, id) + } + + var flagResults []FlagResult + if len(productIDs) > 0 { + err = r.DB().WithContext(ctx). + Table("products p"). + Select("p.id as product_id, STRING_AGG(DISTINCT f.name, ', ') as flag_names"). + Joins("LEFT JOIN flags f ON f.flagable_type = 'products' AND f.flagable_id = p.id"). + Where("p.id IN ?", productIDs). + Group("p.id"). + Scan(&flagResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get product flags: %w", err) + } + } + fmt.Printf("[REPO] Fetched flags for %d products\n", len(flagResults)) + + // Build flag map + flagMap := make(map[uint]string) + for _, flag := range flagResults { + flagMap[flag.ProductID] = flag.FlagNames + } + + // Combine results and calculate average price + results := make([]ProductUsageRow, 0, len(aggregatedMap)) + for _, agg := range aggregatedMap { + avgPrice := float64(0) + if agg.PriceCount > 0 { + avgPrice = agg.Price / float64(agg.PriceCount) + } + + flagNames := flagMap[agg.ProductID] + + // Apply flag filters if provided + if len(flagFilters) > 0 { + // Check if any of the flagFilters exist in flagNames + matched := false + for _, filter := range flagFilters { + if containsIgnoreCase(flagNames, filter) { + matched = true + break + } + } + if !matched { + continue // Skip this product if no flag matches + } + } + + results = append(results, ProductUsageRow{ + ProductID: agg.ProductID, + ProductName: agg.ProductName, + FlagNames: flagNames, + TotalQty: agg.TotalQty, + Price: avgPrice, + TotalPengeluaran: agg.TotalQty * avgPrice, + }) + } + + fmt.Printf("[REPO] After filtering with flagFilters=%v: %d results\n", flagFilters, len(results)) + for i, r := range results { + fmt.Printf("[REPO] Result[%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f\n", + i, r.ProductID, r.ProductName, r.FlagNames, r.TotalQty, r.Price, r.TotalPengeluaran) + } + + // Sort by product name + sort.Slice(results, func(i, j int) bool { + return results[i].ProductName < results[j].ProductName + }) + + fmt.Printf("[REPO] Final sorted results: %d items\n", len(results)) + return results, nil +} + +// GetTotalDepletionByProjectFlockKandangID gets total depletion for a specific kandang +func (r *ClosingKeuanganRepositoryImpl) GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + var result float64 + err := r.DB().WithContext(ctx). + Table("recording_depletions"). + Select("COALESCE(SUM(recording_depletions.qty), 0)"). + Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). + Where("project_flock_kandangs.id = ?", projectFlockKandangID). + Scan(&result).Error + return result, err +} + +// GetTotalWeightProducedFromUniformityByProjectFlockKandangID calculates total weight produced from uniformity data for a specific kandang +// Formula: (mean_up / 1.10) * chick_qty_of_weight / 1000 +func (r *ClosingKeuanganRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + if projectFlockKandangID == 0 { + return 0, nil + } + + var uniformity struct { + MeanUp float64 + ChickQtyOfWeight float64 + } + + err := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity"). + Select("mean_up, chick_qty_of_weight"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Order("id DESC"). + Limit(1). + Scan(&uniformity).Error + + if err != nil { + return 0, err + } + + // Calculate weight: (mean_up / 1.10) * chick_qty_of_weight / 1000 + totalWeight := (uniformity.MeanUp / 1.10) * uniformity.ChickQtyOfWeight / 1000 + + return totalWeight, nil +} + +// containsIgnoreCase checks if a string contains a substring (case-insensitive) +func containsIgnoreCase(str, substr string) bool { + return strings.Contains(strings.ToUpper(str), strings.ToUpper(substr)) +} diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 1cd4559d..f0a6ca2a 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -9,8 +9,8 @@ import ( "github.com/gofiber/fiber/v2" ) -func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService) { - ctrl := controller.NewClosingController(s, sapronakSvc) +func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService, closingKeuanganSvc closing.ClosingKeuanganService) { + ctrl := controller.NewClosingController(s, sapronakSvc, closingKeuanganSvc) route := v1.Group("/closings") route.Use(m.Auth(u)) @@ -34,5 +34,6 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang) route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan) + route.Get("/:project_flock_id/:project_flock_kandang_id/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuanganByKandang) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 6b0b56f6..8cda7220 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -40,7 +40,6 @@ type ClosingService interface { GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) - GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) } @@ -576,82 +575,6 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl return &result, nil } -func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) { - - if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, - ); err != nil { - return nil, err - } - - projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") - } - - budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") - } - - actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost") - } - - purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows) - - realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") - } - - deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { - return db.Preload("MarketingProduct"). - Preload("MarketingProduct.ProductWarehouse"). - Preload("MarketingProduct.ProductWarehouse.Product") - }) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products") - } - - chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") - } - - totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) - } - - totalEggWeightKg, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - s.Log.Warnf("GetTotalEggProductionWeightByProjectFlockID error: %v", err) - } - - totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) - } - - input := dto.ClosingKeuanganInput{ - ProjectFlockCategory: projectFlock.Category, - PurchaseItems: purchaseItems, - Budgets: budgets, - Realizations: realizations, - DeliveryProducts: deliveryProducts, - Chickins: chickins, - TotalWeightProduced: totalWeightProduced, - TotalEggWeightKg: totalEggWeightKg, - TotalDepletion: totalDepletion, - } - - report := dto.ToClosingKeuanganReport(input) - - return &report, nil -} - func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") @@ -1108,52 +1031,3 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl return closest.Mortality, closest.FcrNumber } -func (s closingService) convertActualUsageToPurchaseItems(ctx context.Context, actualUsageRows []repository.ActualUsageCostRow) []entity.PurchaseItem { - if len(actualUsageRows) == 0 { - return []entity.PurchaseItem{} - } - - // Collect all product IDs - productIDs := make([]uint, len(actualUsageRows)) - for i, row := range actualUsageRows { - productIDs[i] = row.ProductID - } - - // Fetch products with flags from repository - products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, productIDs) - if err != nil { - s.Log.Warnf("Failed to fetch products for actual usage: %v", err) - products = []entity.Product{} - } - - // Create product map - productMap := make(map[uint]*entity.Product) - for i := range products { - productMap[products[i].Id] = &products[i] - } - - // Convert to pseudo purchase items - purchaseItems := make([]entity.PurchaseItem, 0, len(actualUsageRows)) - for _, row := range actualUsageRows { - product := productMap[row.ProductID] - - // Skip if product not found - if product == nil { - s.Log.Warnf("Product ID %d not found for actual usage", row.ProductID) - continue - } - - purchaseItem := entity.PurchaseItem{ - Id: 0, // Pseudo item, no ID - ProductId: row.ProductID, - TotalQty: row.TotalQty, - TotalPrice: row.TotalPrice, - Price: row.AveragePrice, - Product: product, - } - - purchaseItems = append(purchaseItems, purchaseItem) - } - - return purchaseItems -} diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go new file mode 100644 index 00000000..0f3351f7 --- /dev/null +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -0,0 +1,640 @@ +package service + +import ( + "errors" + "strings" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" + expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// ClosingKeuanganService handles closing keuangan business logic +type ClosingKeuanganService interface { + GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganData, error) + GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) +} + +type closingKeuanganService struct { + Log *logrus.Logger + ClosingKeuanganRepo repository.ClosingKeuanganRepository + ProjectFlockRepo projectflockRepository.ProjectflockRepository + ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository + MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository + ExpenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository + ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository + ChickinRepo chickinRepository.ProjectChickinRepository + RecordingRepo recordingRepository.RecordingRepository +} + +func NewClosingKeuanganService( + closingKeuanganRepo repository.ClosingKeuanganRepository, + projectFlockRepo projectflockRepository.ProjectflockRepository, + projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository, + marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, + expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, + projectBudgetRepo projectflockRepository.ProjectBudgetRepository, + chickinRepo chickinRepository.ProjectChickinRepository, + recordingRepo recordingRepository.RecordingRepository, +) ClosingKeuanganService { + return &closingKeuanganService{ + Log: utils.Log, + ClosingKeuanganRepo: closingKeuanganRepo, + ProjectFlockRepo: projectFlockRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + MarketingDeliveryProductRepo: marketingDeliveryProductRepo, + ExpenseRealizationRepo: expenseRealizationRepo, + ProjectBudgetRepo: projectBudgetRepo, + ChickinRepo: chickinRepo, + RecordingRepo: recordingRepo, + } +} + +func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganData, error) { + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, + ); err != nil { + return nil, err + } + + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") + } + + // Preload Nonstock.Flags manually + var budgetIDs []uint + for _, b := range budgets { + budgetIDs = append(budgetIDs, b.Id) + } + if len(budgetIDs) > 0 { + err = s.ProjectBudgetRepo.DB().WithContext(c.Context()). + Preload("Nonstock.Flags"). + Where("id IN ?", budgetIDs). + Find(&budgets).Error + } + + // Get all kandang for this project flock + kandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") + } + + return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID) +} + +func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) { + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, + ); err != nil { + return nil, err + } + + // Validate and fetch project flock kandang + kandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") + } + if kandang.ProjectFlockId != projectFlockID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang does not belong to this project flock") + } + + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") + } + + // Preload Nonstock.Flags manually + var budgetIDs []uint + for _, b := range budgets { + budgetIDs = append(budgetIDs, b.Id) + } + if len(budgetIDs) > 0 { + err = s.ProjectBudgetRepo.DB().WithContext(c.Context()). + Preload("Nonstock.Flags"). + Where("id IN ?", budgetIDs). + Find(&budgets).Error + } + + kandangs := []entity.ProjectFlockKandang{*kandang} + + return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID) +} + +func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, budgets []entity.ProjectBudget, kandangs []entity.ProjectFlockKandang, scopeID uint) (*dto.ClosingKeuanganData, error) { + // Define flag filters using constants + pakanFilters := []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher)} + ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)} + ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)} + allFilters := append(pakanFilters, ovkFilters...) + allFilters = append(allFilters, ayamFilters...) + + var allProductUsageRows []repository.ProductUsageRow + + // Get ALL product usage + for _, kandang := range kandangs { + rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters) + if err == nil { + allProductUsageRows = append(allProductUsageRows, rows...) + } + } + + // Classify into categories based on flag priority + var pakanProductUsageRows []repository.ProductUsageRow + var ovkProductUsageRows []repository.ProductUsageRow + var ayamProductUsageRows []repository.ProductUsageRow + + for _, row := range allProductUsageRows { + // Parse flag names from comma-separated string + flagNames := strings.Split(row.FlagNames, ",") + + hasPakanFlag := false + hasOvkFlag := false + hasAyamFlag := false + + for _, flag := range flagNames { + flag = strings.TrimSpace(flag) + if containsItem(pakanFilters, flag) { + hasPakanFlag = true + } + if containsItem(ovkFilters, flag) { + hasOvkFlag = true + } + if containsItem(ayamFilters, flag) { + hasAyamFlag = true + } + } + + // Priority: PAKAN > OVK > AYAM + if hasPakanFlag { + pakanProductUsageRows = append(pakanProductUsageRows, row) + } else if hasOvkFlag { + ovkProductUsageRows = append(ovkProductUsageRows, row) + } else if hasAyamFlag { + ayamProductUsageRows = append(ayamProductUsageRows, row) + } else { + continue + } + } + + + // Calculate total price for each category + var totalPakanPrice, totalOvkPrice, totalAyamPrice float64 + for _, row := range pakanProductUsageRows { + totalPakanPrice += row.TotalPengeluaran + } + for _, row := range ovkProductUsageRows { + totalOvkPrice += row.TotalPengeluaran + } + for _, row := range ayamProductUsageRows { + totalAyamPrice += row.TotalPengeluaran + } + + // Determine if this is per-kandang or per-project-flock scope + isPerKandang := len(kandangs) == 1 + var projectFlockKandangID *uint + if isPerKandang { + kandangID := kandangs[0].Id + projectFlockKandangID = &kandangID + } + + var err error + + // Fetch realizations + var realizations []entity.ExpenseRealization + if isPerKandang && projectFlockKandangID != nil { + realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID) + } else { + realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, nil) + } + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") + } + + deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlock.Id, func(db *gorm.DB) *gorm.DB { + db = db.Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product") + return db + }) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products") + } + + // Filter by kandang if scope is per-kandang (manual filtering after fetch) + if isPerKandang && projectFlockKandangID != nil { + filteredProducts := make([]entity.MarketingDeliveryProduct, 0) + for _, dp := range deliveryProducts { + pfKandangID := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandangId + if pfKandangID != nil && *pfKandangID == *projectFlockKandangID { + filteredProducts = append(filteredProducts, dp) + } + } + deliveryProducts = filteredProducts + } + + // Fetch chickins + var chickins []entity.ProjectChickin + if isPerKandang && projectFlockKandangID != nil { + chickins, err = s.ChickinRepo.GetByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + chickins, err = s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlock.Id) + } + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") + } + + // Get total depletion + var totalDepletion float64 + if isPerKandang && projectFlockKandangID != nil { + totalDepletion, err = s.ClosingKeuanganRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + totalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id) + } + if err != nil { + totalDepletion = 0 + } + + totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlock.Id) + if err != nil { + } + + // Try to get actual weight from uniformity data + var totalWeightFromUniformity float64 + if isPerKandang && projectFlockKandangID != nil { + totalWeightFromUniformity, err = s.ClosingKeuanganRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + totalWeightFromUniformity, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id) + } + if err != nil { + } else if totalWeightFromUniformity > 0 { + totalWeightProduced = totalWeightFromUniformity + } + + // Fetch egg data only for Laying category + var totalEggWeightKg float64 + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + // TODO: Replace with actual method to get egg weight from RecordingRepo + // totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlock.Id) + // For now, set to 0 as placeholder + totalEggWeightKg = 0 + } else { + totalEggWeightKg = 0 + } + + // Build new DTO structure + + // Calculate totals + var totalPopulation float64 + for _, chickin := range chickins { + totalPopulation += chickin.UsageQty + } + + // Calculate actual population (total population - depletion) + actualPopulation := totalPopulation - totalDepletion + + // Calculate budget totals by category + calculateBudgetByFlag := func(flags []string) float64 { + var total float64 + for _, budget := range budgets { + if budget.Nonstock != nil { + for _, nonstockFlag := range budget.Nonstock.Flags { + flagName := strings.ToUpper(nonstockFlag.Name) + for _, targetFlag := range flags { + if flagName == strings.ToUpper(targetFlag) { + total += budget.Price * budget.Qty + break + } + } + } + } + } + return total + } + + // Budget per category + budgetPakan := calculateBudgetByFlag([]string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}) + budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA"}) + budgetAyam := calculateBudgetByFlag([]string{"DOC", "PULLET", "LAYER"}) + budgetEkspedisi := calculateBudgetByFlag([]string{"EKSPEDISI"}) + + // Operational budget = total budget - pakan - ovk - ayam - ekspedisi + totalBudgetAmount := 0.0 + for _, budget := range budgets { + totalBudgetAmount += budget.Price * budget.Qty + } + budgetOperational := totalBudgetAmount - budgetPakan - budgetOvk - budgetAyam - budgetEkspedisi + + + // Calculate realization totals + var totalRealizationAmount float64 + var totalEkspedisiRealization float64 + for _, realization := range realizations { + amount := realization.Price * realization.Qty + totalRealizationAmount += amount + + // Check if this is ekspedisi (need to check nonstock flags) + if realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Nonstock != nil { + for _, flag := range realization.ExpenseNonstock.Nonstock.Flags { + if flag.Name == "EKSPEDISI" { + totalEkspedisiRealization += amount + break + } + } + } + } + + totalOperationalRealization := totalRealizationAmount - totalEkspedisiRealization + + // Filter delivery products based on category + var filteredDeliveryProducts []entity.MarketingDeliveryProduct + for _, delivery := range deliveryProducts { + // Get product from delivery + if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 { + continue + } + + product := delivery.MarketingProduct.ProductWarehouse.Product + isEggProduct := false + isChickenProduct := false + + // Check product flags + for _, flag := range product.Flags { + flagName := strings.ToUpper(flag.Name) + + // Egg product flags + if flagName == "TELUR" || flagName == "TELURUTUH" || flagName == "TELURPECAH" || + flagName == "TELURPUTIH" || flagName == "TELURRETAK" { + isEggProduct = true + } + + // Chicken product flags + if flagName == "AYAMAFKIR" || flagName == "AYAMCULLING" || flagName == "AYAMMATI" { + isChickenProduct = true + } + } + + // Filter based on project flock category + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + // Laying: only egg products + if isEggProduct { + filteredDeliveryProducts = append(filteredDeliveryProducts, delivery) + } + } else { + // Growing/Contract Growing: only chicken products + if isChickenProduct || (!isEggProduct && !isChickenProduct) { + // Include if chicken product or if no specific flags (default to chicken) + filteredDeliveryProducts = append(filteredDeliveryProducts, delivery) + } + } + } + + + // Calculate total weight sold and sales amount from filtered products + var totalWeightSold float64 + var totalSalesAmount float64 + for _, delivery := range filteredDeliveryProducts { + totalWeightSold += delivery.TotalWeight + totalSalesAmount += delivery.TotalPrice + } + + + // Calculate metrics - always use kg ayam for rp_per_kg + calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if actualPopulation > 0 { + rpPerBird = amount / actualPopulation // Use actual population + } + if totalWeightProduced > 0 { + rpPerKg = amount / totalWeightProduced + } + return + } + + // Calculate metrics for profit loss (use total population and total weight produced) + calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if totalPopulation > 0 { + rpPerBird = amount / totalPopulation + } + if totalWeightProduced > 0 { + rpPerKg = amount / totalWeightProduced + } + return + } + + // Build HPP Items using constants + hppItems := []dto.HPPItem{} + + // PAKAN item + pakanBudgetRpPerBird, pakanBudgetRpPerKg := calculateMetrics(budgetPakan) + pakanRealizationRpPerBird, pakanRealizationRpPerKg := calculateMetrics(totalPakanPrice) + hppItems = append(hppItems, dto.ToHPPItem( + 1, + "purchase", + string(dto.HPPCodePakan), + "Pembelian Pakan", + dto.ToFinancialMetrics(pakanBudgetRpPerBird, pakanBudgetRpPerKg, budgetPakan), + dto.ToFinancialMetrics(pakanRealizationRpPerBird, pakanRealizationRpPerKg, totalPakanPrice), + )) + + // OVK item + ovkBudgetRpPerBird, ovkBudgetRpPerKg := calculateMetrics(budgetOvk) + ovkRealizationRpPerBird, ovkRealizationRpPerKg := calculateMetrics(totalOvkPrice) + hppItems = append(hppItems, dto.ToHPPItem( + 2, + "purchase", + string(dto.HPPCodeOVK), + "Pembelian OVK", + dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk), + dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice), + )) + + // DOC/DEPRESIASI item + docCode := string(dto.HPPCodeDOC) + docLabel := "Pembelian DOC" + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + docCode = string(dto.HPPCodeDepresiasi) + docLabel = "Depresiasi" + } + docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam) + docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice) + hppItems = append(hppItems, dto.ToHPPItem( + 3, + "purchase", + docCode, + docLabel, + dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam), + dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice), + )) + + // OVERHEAD item + overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational) + overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization) + hppItems = append(hppItems, dto.ToHPPItem( + 4, + "overhead", + string(dto.HPPCodeOverhead), + "Pengeluaran Overhead", + dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational), + dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization), + )) + + // EKSPEDISI item + ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi) + ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization) + hppItems = append(hppItems, dto.ToHPPItem( + 5, + "overhead", + string(dto.HPPCodeEkspedisi), + "Beban Ekspedisi", + dto.ToFinancialMetrics(ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg, budgetEkspedisi), + dto.ToFinancialMetrics(ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg, totalEkspedisiRealization), + )) + + // HPP Summary + totalBudgetHpp := budgetPakan + budgetOvk + budgetAyam + budgetOperational + budgetEkspedisi + totalRealizationHpp := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization + + hppBudgetRpPerBird, hppBudgetRpPerKg := calculateMetrics(totalBudgetHpp) + hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp) + + var eggBudgeting, eggRealization *dto.FinancialMetrics + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) && totalEggWeightKg > 0 { + eggBudgetRpPerKg := totalBudgetHpp / totalEggWeightKg + eggRealizationRpPerKg := totalRealizationHpp / totalEggWeightKg + eggBudgeting = &dto.FinancialMetrics{ + RpPerBird: 0, + RpPerKg: eggBudgetRpPerKg, + Amount: totalBudgetHpp, + } + eggRealization = &dto.FinancialMetrics{ + RpPerBird: 0, + RpPerKg: eggRealizationRpPerKg, + Amount: totalRealizationHpp, + } + } + + hppSummary := dto.ToHPPSummary( + "HPP", + dto.ToFinancialMetrics(hppBudgetRpPerBird, hppBudgetRpPerKg, totalBudgetHpp), + dto.ToFinancialMetrics(hppRealizationRpPerBird, hppRealizationRpPerKg, totalRealizationHpp), + eggBudgeting, + eggRealization, + ) + + hppSection := dto.ToHPPSection(hppItems, hppSummary) + + // Build Profit Loss Items using constants + plItems := []dto.ProfitLossItem{} + + // SALES item + salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount) + salesLabel := "Penjualan Ayam" + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + salesLabel = "Penjualan Telur" + } + plItems = append(plItems, dto.ToProfitLossItem( + string(dto.PLCodeSales), + salesLabel, + "income", + salesRpPerBird, + salesRpPerKg, + totalSalesAmount, + )) + + // SAPRONAK item - combines DOC/Depresiasi + PAKAN + OVK + totalSapronakAmount := totalAyamPrice + totalPakanPrice + totalOvkPrice + sapronakRpPerBird := docRealizationRpPerBird + pakanRealizationRpPerBird + ovkRealizationRpPerBird + sapronakRpPerKg := docRealizationRpPerKg + pakanRealizationRpPerKg + ovkRealizationRpPerKg + sapronakLabel := "Pengeluaran Sapronak" + plItems = append(plItems, dto.ToProfitLossItem( + string(dto.PLCodeSapronak), + sapronakLabel, + "purchase", + sapronakRpPerBird, + sapronakRpPerKg, + totalSapronakAmount, + )) + + // OVERHEAD item + overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization) + plItems = append(plItems, dto.ToProfitLossItem( + string(dto.PLCodeOverhead), + "Overhead", + "overhead", + overheadRpPerBird, + overheadRpPerKg, + totalOperationalRealization, + )) + + // EKSPEDISI item + plItems = append(plItems, dto.ToProfitLossItem( + string(dto.PLCodeEkspedisi), + "Ekspedisi", + "overhead", + ekspedisiRealizationRpPerBird, + ekspedisiRealizationRpPerKg, + totalEkspedisiRealization, + )) + + // Profit Loss Summary + // Gross Profit = Sales - (DOC + PAKAN + OVK) only + // Gross Profit should NOT include overhead and ekspedisi + costOfGoodsSold := totalAyamPrice + totalPakanPrice + totalOvkPrice + costOfGoodsSoldRpPerBird := sapronakRpPerBird + + grossProfit := totalSalesAmount - costOfGoodsSold + grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird + + // Operating Expenses (Overhead + Ekspedisi) + totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization + totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird + + // Net Profit = Gross Profit - Operating Expenses + netProfit := grossProfit - totalOperatingExpenses + netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird + + plSummary := dto.ToProfitLossSummary( + dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit), + dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, 0, totalOperatingExpenses), + dto.ToFinancialMetrics(netProfitRpPerBird, 0, netProfit), + ) + + profitLossSection := dto.ToProfitLossSection(plItems, plSummary) + + // Build complete response + data := dto.ToClosingKeuanganData(hppSection, profitLossSection) + + return &data, nil +} + +// containsItem checks if a string exists in a slice +func containsItem(slice []string, item string) bool { + for _, s := range slice { + if strings.EqualFold(s, item) { + return true + } + } + return false +} diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index fbb628ff..703c05f0 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -44,6 +44,7 @@ type RecordingRepository interface { GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) + GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) @@ -548,3 +549,30 @@ func nextRecordingDay(days []int) int { return len(normalized) + 1 } + +// GetTotalWeightProducedFromUniformityByProjectFlockID calculates total weight produced from uniformity data +// It takes the latest uniformity record per kandang and calculates: SUM(mean_weight * chick_qty_of_weight / 1000) +func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { + if projectFlockID == 0 { + return 0, nil + } + + var result struct { + TotalWeight float64 + } + + err := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity"). + Select("COALESCE(SUM((mean_up / 1.10) * chick_qty_of_weight / 1000), 0) as total_weight"). + Joins("JOIN ("+ + " SELECT pfku.project_flock_kandang_id, MAX(pfku.id) as latest_id "+ + " FROM project_flock_kandang_uniformity pfku "+ + " JOIN project_flock_kandangs pfk ON pfk.id = pfku.project_flock_kandang_id "+ + " WHERE pfk.project_flock_id = ? "+ + " GROUP BY pfku.project_flock_kandang_id "+ + ") latest ON latest.project_flock_kandang_id = project_flock_kandang_uniformity.project_flock_kandang_id "+ + "AND project_flock_kandang_uniformity.id = latest.latest_id", projectFlockID). + Scan(&result).Error + + return result.TotalWeight, err +}