diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go index 90dda2a9..08bfb5fc 100644 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ b/internal/modules/closings/dto/closingKeuangan.dto.go @@ -35,6 +35,7 @@ const ( type CalculationContext struct { TotalPopulation float64 TotalWeightProduced float64 + TotalEggWeightKg float64 TotalDepletion float64 TotalWeightSold float64 ActualPopulation float64 @@ -48,6 +49,7 @@ type ClosingKeuanganInput struct { DeliveryProducts []entities.MarketingDeliveryProduct Chickins []entities.ProjectChickin TotalWeightProduced float64 + TotalEggWeightKg float64 TotalDepletion float64 } @@ -77,8 +79,10 @@ type HppGroup struct { } type SummaryHpp struct { - Label string `json:"label"` - Comparison + Label string `json:"label"` + Comparison `json:"-"` + EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` + EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` } type HppPurchasesSection struct { @@ -231,7 +235,7 @@ func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entiti // === HPP SUMMARY === -func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) SummaryHpp { +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 @@ -241,16 +245,34 @@ func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets [ budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced) realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) - return SummaryHpp{ + summary := SummaryHpp{ Label: label, Comparison: ToComparison( ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), 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, ctx CalculationContext) HppPurchasesSection { +func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection { hppGroups := []HppGroup{ { GroupName: HPPGroupPengeluaran, @@ -259,7 +281,7 @@ func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []enti ToHppBahanBakuGroup(budgets, realizations, ctx), } - summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, ctx) + summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx) return HppPurchasesSection{ Hpp: hppGroups, @@ -322,11 +344,9 @@ func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.M func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { purchaseAmount := sumPurchaseTotal(purchases) - bopAmount := getOperationalExpenses(realizations) - totalCost := purchaseAmount + bopAmount return []PLItem{ - createPLItemWithMetrics(PLItemTypeSapronak, totalCost, ctx), + createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx), } } @@ -414,12 +434,13 @@ func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse { 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, ctx) + 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) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index cf49826a..e3f09dda 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -31,6 +31,8 @@ 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) } type ClosingRepositoryImpl struct { @@ -804,3 +806,150 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand }) return in, out, 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 + + // Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll) + purchaseStockableKey := "PURCHASE_ITEMS" + transferStockableKey := "STOCK_TRANSFER_DETAILS" + + 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.quantity, 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.quantity, 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.quantity, 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.quantity, 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.quantity, 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 + } + + // Part 2: Get usage from project_chickins (DOC, Pullet) + 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 + } + + // Merge results + 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 + } + + var products []entity.Product + err := r.DB().WithContext(ctx). + Preload("Flags"). + Where("id IN ?", productIDs). + Find(&products).Error + + if err != nil { + return nil, err + } + + return products, nil +} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index ab8e6f7b..9f643a78 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -426,11 +426,15 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") } - purchaseItems, err := s.PurchaseRepo.GetItemsByProjectFlockID(c.Context(), projectFlockID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch purchase items") + // Get actual usage cost instead of purchase items + actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost") } + // Convert actual usage rows to pseudo purchase items + 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") @@ -455,6 +459,11 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* 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) @@ -468,6 +477,7 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* DeliveryProducts: deliveryProducts, Chickins: chickins, TotalWeightProduced: totalWeightProduced, + TotalEggWeightKg: totalEggWeightKg, TotalDepletion: totalDepletion, } @@ -476,8 +486,6 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* return &report, nil } -// GetExpeditionHPP menghitung HPP ekspedisi per vendor untuk sebuah project flock. -// Jika projectFlockKandangID tidak nil, maka hanya data untuk kandang tersebut yang dihitung. 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") @@ -778,5 +786,54 @@ 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 }