diff --git a/internal/modules/repports/dto/repportBalanceMonitoring.dto.go b/internal/modules/repports/dto/repportBalanceMonitoring.dto.go new file mode 100644 index 00000000..8a63729b --- /dev/null +++ b/internal/modules/repports/dto/repportBalanceMonitoring.dto.go @@ -0,0 +1,71 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" +) + +type BalanceMonitoringAyamDTO struct { + Ekor float64 `json:"ekor"` + Kg float64 `json:"kg"` + Nominal float64 `json:"nominal"` +} + +type BalanceMonitoringTelurDTO struct { + Butir float64 `json:"butir"` + Kg float64 `json:"kg"` + Nominal float64 `json:"nominal"` +} + +type BalanceMonitoringTradingDTO struct { + Qty float64 `json:"qty"` + Kg float64 `json:"kg"` + Nominal float64 `json:"nominal"` +} + +type BalanceMonitoringRowDTO struct { + Customer customerDTO.CustomerRelationDTO `json:"customer"` + SaldoAwal float64 `json:"saldo_awal"` + PenjualanAyam BalanceMonitoringAyamDTO `json:"penjualan_ayam"` + PenjualanTelur BalanceMonitoringTelurDTO `json:"penjualan_telur"` + PenjualanTrading BalanceMonitoringTradingDTO `json:"penjualan_trading"` + Pembayaran float64 `json:"pembayaran"` + Aging int `json:"aging"` + AgingRataRata float64 `json:"aging_rata_rata"` + SaldoAkhir float64 `json:"saldo_akhir"` +} + +type BalanceMonitoringTotalsDTO struct { + SaldoAwal float64 `json:"saldo_awal"` + PenjualanAyam BalanceMonitoringAyamDTO `json:"penjualan_ayam"` + PenjualanTelur BalanceMonitoringTelurDTO `json:"penjualan_telur"` + PenjualanTrading BalanceMonitoringTradingDTO `json:"penjualan_trading"` + Pembayaran float64 `json:"pembayaran"` + Aging int `json:"aging"` + AgingRataRata float64 `json:"aging_rata_rata"` + SaldoAkhir float64 `json:"saldo_akhir"` +} + +func ToBalanceMonitoringRowDTO( + customer entity.Customer, + saldoAwal float64, + ayam BalanceMonitoringAyamDTO, + telur BalanceMonitoringTelurDTO, + trading BalanceMonitoringTradingDTO, + pembayaran float64, + aging int, + agingRataRata float64, +) BalanceMonitoringRowDTO { + saldoAkhir := saldoAwal + pembayaran - (ayam.Nominal + telur.Nominal + trading.Nominal) + return BalanceMonitoringRowDTO{ + Customer: customerDTO.ToCustomerRelationDTO(customer), + SaldoAwal: saldoAwal, + PenjualanAyam: ayam, + PenjualanTelur: telur, + PenjualanTrading: trading, + Pembayaran: pembayaran, + Aging: aging, + AgingRataRata: agingRataRata, + SaldoAkhir: saldoAkhir, + } +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 110bbc93..62f26794 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -40,6 +40,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) + balanceMonitoringRepository := repportRepo.NewBalanceMonitoringRepository(db) customerRepository := customerRepo.NewCustomerRepository(db) standardGrowthDetailRepository := productionStandardRepo.NewStandardGrowthDetailRepository(db) productionStandardDetailRepository := productionStandardRepo.NewProductionStandardDetailRepository(db) @@ -66,6 +67,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * hppPerKandangRepository, productionResultRepository, customerPaymentRepository, + balanceMonitoringRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository, diff --git a/internal/modules/repports/repositories/balance_monitoring.repository.go b/internal/modules/repports/repositories/balance_monitoring.repository.go new file mode 100644 index 00000000..e1da6952 --- /dev/null +++ b/internal/modules/repports/repositories/balance_monitoring.repository.go @@ -0,0 +1,518 @@ +package repositories + +import ( + "context" + "fmt" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "gorm.io/gorm" +) + +type BalanceMonitoringCategoryRow struct { + CustomerID uint `gorm:"column:customer_id"` + AyamQty float64 `gorm:"column:ayam_qty"` + AyamKg float64 `gorm:"column:ayam_kg"` + AyamNominal float64 `gorm:"column:ayam_nominal"` + TelurQty float64 `gorm:"column:telur_qty"` + TelurKg float64 `gorm:"column:telur_kg"` + TelurNominal float64 `gorm:"column:telur_nominal"` + TradingQty float64 `gorm:"column:trading_qty"` + TradingKg float64 `gorm:"column:trading_kg"` + TradingNominal float64 `gorm:"column:trading_nominal"` +} + +type BalanceMonitoringAgingRow struct { + CustomerID uint `gorm:"column:customer_id"` + AgingMax int `gorm:"column:aging_max"` + AgingRataRata float64 `gorm:"column:aging_rata_rata"` +} + +type BalanceMonitoringGrandTotalsRow struct { + SaldoAwalLifetime float64 `gorm:"column:saldo_awal_lifetime"` + SalesBeforeStart float64 `gorm:"column:sales_before_start"` + PaymentBeforeStart float64 `gorm:"column:payment_before_start"` + AyamQty float64 `gorm:"column:ayam_qty"` + AyamKg float64 `gorm:"column:ayam_kg"` + AyamNominal float64 `gorm:"column:ayam_nominal"` + TelurQty float64 `gorm:"column:telur_qty"` + TelurKg float64 `gorm:"column:telur_kg"` + TelurNominal float64 `gorm:"column:telur_nominal"` + TradingQty float64 `gorm:"column:trading_qty"` + TradingKg float64 `gorm:"column:trading_kg"` + TradingNominal float64 `gorm:"column:trading_nominal"` + PaymentInPeriod float64 `gorm:"column:payment_in_period"` + AgingMax int `gorm:"column:aging_max"` + AgingRataRata float64 `gorm:"column:aging_rata_rata"` +} + +type BalanceMonitoringRepository interface { + GetCustomerIDsForBalanceMonitoring(ctx context.Context, offset, limit int, filters *validation.BalanceMonitoringQuery) ([]uint, int64, error) + GetAllFilteredCustomerIDs(ctx context.Context, filters *validation.BalanceMonitoringQuery) ([]uint, error) + GetSaldoAwalLifetime(ctx context.Context, customerIDs []uint) (map[uint]float64, error) + GetSalesTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) + GetPaymentTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) + GetSalesByCategoryInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringCategoryRow, error) + GetPaymentTotalsInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) + GetAgingPerCustomer(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringAgingRow, error) + GetGrandTotals(ctx context.Context, filters *validation.BalanceMonitoringQuery) (BalanceMonitoringGrandTotalsRow, error) +} + +type balanceMonitoringRepositoryImpl struct { + db *gorm.DB +} + +func NewBalanceMonitoringRepository(db *gorm.DB) BalanceMonitoringRepository { + return &balanceMonitoringRepositoryImpl{db: db} +} + +func resolveBalanceMonitoringDateColumn(filterBy string) string { + switch strings.ToLower(strings.TrimSpace(filterBy)) { + case "realized_at": + return "mdp.delivery_date" + case "sold_at", "": + return "m.so_date" + default: + return "m.so_date" + } +} + +func resolveBalanceMonitoringDateRange(filters *validation.BalanceMonitoringQuery) (time.Time, time.Time, error) { + var startDate time.Time + var endDate time.Time + var err error + + if strings.TrimSpace(filters.StartDate) != "" { + startDate, err = utils.ParseDateString(filters.StartDate) + if err != nil { + return time.Time{}, time.Time{}, err + } + } else { + startDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + if strings.TrimSpace(filters.EndDate) != "" { + endDate, err = utils.ParseDateString(filters.EndDate) + if err != nil { + return time.Time{}, time.Time{}, err + } + } else { + endDate = time.Now() + } + + return startDate, endDate, nil +} + +func resolveBalanceMonitoringSortClause(filters *validation.BalanceMonitoringQuery) string { + direction := "ASC" + if strings.EqualFold(strings.TrimSpace(filters.SortOrder), "desc") { + direction = "DESC" + } + switch strings.ToLower(strings.TrimSpace(filters.SortBy)) { + case "customer": + return "customers.name " + direction + default: + return "customers.name ASC" + } +} + +func (r *balanceMonitoringRepositoryImpl) baseCustomerQuery(ctx context.Context, filters *validation.BalanceMonitoringQuery) *gorm.DB { + db := r.db.WithContext(ctx). + Model(&entity.Customer{}). + Where("customers.deleted_at IS NULL") + + if len(filters.CustomerIDs) > 0 { + db = db.Where("customers.id IN ?", filters.CustomerIDs) + } + + if len(filters.SalesIDs) > 0 { + db = db.Where("EXISTS (SELECT 1 FROM marketings m WHERE m.customer_id = customers.id AND m.deleted_at IS NULL AND m.sales_person_id IN ?)", filters.SalesIDs) + } + + if filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil { + scopeSub := r.db.WithContext(ctx). + Table("marketings m"). + Select("1"). + Joins("JOIN marketing_products mp ON mp.marketing_id = m.id"). + Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). + Joins("JOIN product_warehouses pw ON pw.id = mdp.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("m.customer_id = customers.id"). + Where("m.deleted_at IS NULL"). + Where("mdp.delivery_date IS NOT NULL") + + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + scopeSub = scopeSub.Where("w.area_id IN ?", filters.AllowedAreaIDs) + } + } + if filters.AllowedLocationIDs != nil { + if len(filters.AllowedLocationIDs) == 0 { + db = db.Where("1 = 0") + } else { + scopeSub = scopeSub.Where("w.location_id IN ?", filters.AllowedLocationIDs) + } + } + + db = db.Where("EXISTS (?)", scopeSub) + } + + return db +} + +func (r *balanceMonitoringRepositoryImpl) GetCustomerIDsForBalanceMonitoring(ctx context.Context, offset, limit int, filters *validation.BalanceMonitoringQuery) ([]uint, int64, error) { + var total int64 + if err := r.baseCustomerQuery(ctx, filters).Count(&total).Error; err != nil { + return nil, 0, err + } + if total == 0 { + return []uint{}, 0, nil + } + + if offset < 0 { + offset = 0 + } + + var customerIDs []uint + err := r.baseCustomerQuery(ctx, filters). + Order(resolveBalanceMonitoringSortClause(filters)). + Limit(limit). + Offset(offset). + Pluck("customers.id", &customerIDs). + Error + if err != nil { + return nil, 0, err + } + + return customerIDs, total, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetAllFilteredCustomerIDs(ctx context.Context, filters *validation.BalanceMonitoringQuery) ([]uint, error) { + var customerIDs []uint + if err := r.baseCustomerQuery(ctx, filters).Pluck("customers.id", &customerIDs).Error; err != nil { + return nil, err + } + return customerIDs, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetSaldoAwalLifetime(ctx context.Context, customerIDs []uint) (map[uint]float64, error) { + if len(customerIDs) == 0 { + return map[uint]float64{}, nil + } + + type row struct { + CustomerID uint `gorm:"column:customer_id"` + Total float64 `gorm:"column:total"` + } + rows := make([]row, 0) + err := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("party_id AS customer_id, COALESCE(SUM(nominal), 0) AS total"). + Where("party_type = ?", string(utils.PaymentPartyCustomer)). + Where("transaction_type = ?", string(utils.TransactionTypeSaldoAwal)). + Where("party_id IN ?", customerIDs). + Group("party_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, r := range rows { + result[r.CustomerID] = r.Total + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetSalesTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) { + if len(customerIDs) == 0 { + return map[uint]float64{}, nil + } + + startDate, _, err := resolveBalanceMonitoringDateRange(filters) + if err != nil { + return map[uint]float64{}, nil + } + + dateColumn := resolveBalanceMonitoringDateColumn(filters.FilterBy) + + type row struct { + CustomerID uint `gorm:"column:customer_id"` + Total float64 `gorm:"column:total"` + } + rows := make([]row, 0) + db := r.db.WithContext(ctx). + Table("marketing_delivery_products mdp"). + Select("m.customer_id AS customer_id, COALESCE(SUM(mdp.total_price), 0) AS total"). + Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("INNER JOIN marketings m ON m.id = mp.marketing_id"). + Where("m.customer_id IN ?", customerIDs). + Where("m.deleted_at IS NULL"). + Where("mdp.delivery_date IS NOT NULL"). + Where(fmt.Sprintf("DATE(%s) < ?", dateColumn), startDate) + + if len(filters.SalesIDs) > 0 { + db = db.Where("m.sales_person_id IN ?", filters.SalesIDs) + } + + if err := db.Group("m.customer_id").Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, rr := range rows { + result[rr.CustomerID] = rr.Total + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) { + if len(customerIDs) == 0 { + return map[uint]float64{}, nil + } + + startDate, _, err := resolveBalanceMonitoringDateRange(filters) + if err != nil { + return map[uint]float64{}, nil + } + + type row struct { + CustomerID uint `gorm:"column:customer_id"` + Total float64 `gorm:"column:total"` + } + rows := make([]row, 0) + err = r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("party_id AS customer_id, COALESCE(SUM(nominal), 0) AS total"). + Where("party_type = ?", string(utils.PaymentPartyCustomer)). + Where("transaction_type = ?", string(utils.TransactionTypePenjualan)). + Where("direction = ?", "IN"). + Where("party_id IN ?", customerIDs). + Where("DATE(payment_date) < ?", startDate). + Group("party_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, rr := range rows { + result[rr.CustomerID] = rr.Total + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetSalesByCategoryInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringCategoryRow, error) { + if len(customerIDs) == 0 { + return map[uint]BalanceMonitoringCategoryRow{}, nil + } + + startDate, endDate, err := resolveBalanceMonitoringDateRange(filters) + if err != nil { + return map[uint]BalanceMonitoringCategoryRow{}, nil + } + + dateColumn := resolveBalanceMonitoringDateColumn(filters.FilterBy) + + rows := make([]BalanceMonitoringCategoryRow, 0) + db := r.db.WithContext(ctx). + Table("marketing_delivery_products mdp"). + Select(`m.customer_id AS customer_id, + COALESCE(SUM(CASE WHEN m.marketing_type IN ('AYAM','AYAM_PULLET') THEN mdp.usage_qty ELSE 0 END), 0) AS ayam_qty, + COALESCE(SUM(CASE WHEN m.marketing_type IN ('AYAM','AYAM_PULLET') THEN mdp.total_weight ELSE 0 END), 0) AS ayam_kg, + COALESCE(SUM(CASE WHEN m.marketing_type IN ('AYAM','AYAM_PULLET') THEN mdp.total_price ELSE 0 END), 0) AS ayam_nominal, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TELUR' THEN mdp.usage_qty ELSE 0 END), 0) AS telur_qty, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TELUR' THEN mdp.total_weight ELSE 0 END), 0) AS telur_kg, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TELUR' THEN mdp.total_price ELSE 0 END), 0) AS telur_nominal, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TRADING' THEN mdp.usage_qty ELSE 0 END), 0) AS trading_qty, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TRADING' THEN mdp.total_weight ELSE 0 END), 0) AS trading_kg, + COALESCE(SUM(CASE WHEN m.marketing_type = 'TRADING' THEN mdp.total_price ELSE 0 END), 0) AS trading_nominal`). + Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("INNER JOIN marketings m ON m.id = mp.marketing_id"). + Where("m.customer_id IN ?", customerIDs). + Where("m.deleted_at IS NULL"). + Where("mdp.delivery_date IS NOT NULL"). + Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), startDate). + Where(fmt.Sprintf("DATE(%s) <= ?", dateColumn), endDate) + + if len(filters.SalesIDs) > 0 { + db = db.Where("m.sales_person_id IN ?", filters.SalesIDs) + } + + if err := db.Group("m.customer_id").Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]BalanceMonitoringCategoryRow, len(rows)) + for _, rr := range rows { + result[rr.CustomerID] = rr + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetPaymentTotalsInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) { + if len(customerIDs) == 0 { + return map[uint]float64{}, nil + } + + startDate, endDate, err := resolveBalanceMonitoringDateRange(filters) + if err != nil { + return map[uint]float64{}, nil + } + + type row struct { + CustomerID uint `gorm:"column:customer_id"` + Total float64 `gorm:"column:total"` + } + rows := make([]row, 0) + err = r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("party_id AS customer_id, COALESCE(SUM(nominal), 0) AS total"). + Where("party_type = ?", string(utils.PaymentPartyCustomer)). + Where("transaction_type = ?", string(utils.TransactionTypePenjualan)). + Where("direction = ?", "IN"). + Where("party_id IN ?", customerIDs). + Where("DATE(payment_date) >= ?", startDate). + Where("DATE(payment_date) <= ?", endDate). + Group("party_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, rr := range rows { + result[rr.CustomerID] = rr.Total + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetAgingPerCustomer(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringAgingRow, error) { + if len(customerIDs) == 0 { + return map[uint]BalanceMonitoringAgingRow{}, nil + } + + startDate, endDate, err := resolveBalanceMonitoringDateRange(filters) + if err != nil { + return map[uint]BalanceMonitoringAgingRow{}, nil + } + + dateColumn := resolveBalanceMonitoringDateColumn(filters.FilterBy) + + rows := make([]BalanceMonitoringAgingRow, 0) + db := r.db.WithContext(ctx). + Table("marketing_delivery_products mdp"). + Select(`m.customer_id AS customer_id, + COALESCE(MAX(GREATEST(CURRENT_DATE - DATE(mdp.delivery_date), 0)), 0) AS aging_max, + COALESCE( + SUM(mdp.total_price * GREATEST(CURRENT_DATE - DATE(mdp.delivery_date), 0))::numeric + / NULLIF(SUM(mdp.total_price), 0), + 0 + )::numeric(15,2) AS aging_rata_rata`). + Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("INNER JOIN marketings m ON m.id = mp.marketing_id"). + Where("m.customer_id IN ?", customerIDs). + Where("m.deleted_at IS NULL"). + Where("mdp.delivery_date IS NOT NULL"). + Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), startDate). + Where(fmt.Sprintf("DATE(%s) <= ?", dateColumn), endDate) + + if len(filters.SalesIDs) > 0 { + db = db.Where("m.sales_person_id IN ?", filters.SalesIDs) + } + + if err := db.Group("m.customer_id").Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]BalanceMonitoringAgingRow, len(rows)) + for _, rr := range rows { + result[rr.CustomerID] = rr + } + return result, nil +} + +func (r *balanceMonitoringRepositoryImpl) GetGrandTotals(ctx context.Context, filters *validation.BalanceMonitoringQuery) (BalanceMonitoringGrandTotalsRow, error) { + customerIDs, err := r.GetAllFilteredCustomerIDs(ctx, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + if len(customerIDs) == 0 { + return BalanceMonitoringGrandTotalsRow{}, nil + } + + saldoAwalLifetimeMap, err := r.GetSaldoAwalLifetime(ctx, customerIDs) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + salesBeforeMap, err := r.GetSalesTotalsBeforeDate(ctx, customerIDs, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + paymentBeforeMap, err := r.GetPaymentTotalsBeforeDate(ctx, customerIDs, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + categoryMap, err := r.GetSalesByCategoryInPeriod(ctx, customerIDs, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + paymentInPeriodMap, err := r.GetPaymentTotalsInPeriod(ctx, customerIDs, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + agingMap, err := r.GetAgingPerCustomer(ctx, customerIDs, filters) + if err != nil { + return BalanceMonitoringGrandTotalsRow{}, err + } + + totals := BalanceMonitoringGrandTotalsRow{} + for _, total := range saldoAwalLifetimeMap { + totals.SaldoAwalLifetime += total + } + for _, total := range salesBeforeMap { + totals.SalesBeforeStart += total + } + for _, total := range paymentBeforeMap { + totals.PaymentBeforeStart += total + } + for _, cat := range categoryMap { + totals.AyamQty += cat.AyamQty + totals.AyamKg += cat.AyamKg + totals.AyamNominal += cat.AyamNominal + totals.TelurQty += cat.TelurQty + totals.TelurKg += cat.TelurKg + totals.TelurNominal += cat.TelurNominal + totals.TradingQty += cat.TradingQty + totals.TradingKg += cat.TradingKg + totals.TradingNominal += cat.TradingNominal + } + for _, total := range paymentInPeriodMap { + totals.PaymentInPeriod += total + } + + for _, aging := range agingMap { + totals.AgingMax += aging.AgingMax + } + + weightedSum := 0.0 + weightTotal := 0.0 + for cid, cat := range categoryMap { + nominal := cat.AyamNominal + cat.TelurNominal + cat.TradingNominal + if aging, ok := agingMap[cid]; ok && nominal > 0 { + weightedSum += nominal * aging.AgingRataRata + weightTotal += nominal + } + } + if weightTotal > 0 { + totals.AgingRataRata = weightedSum / weightTotal + } + + return totals, nil +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 16c14de5..56faae35 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -26,4 +26,5 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) + route.Get("/balance-monitoring", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetBalanceMonitoring) } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 3e203c65..4bccd6bd 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -52,6 +52,7 @@ type RepportService interface { GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) + GetBalanceMonitoring(ctx *fiber.Ctx, params *validation.BalanceMonitoringQuery) ([]dto.BalanceMonitoringRowDTO, dto.BalanceMonitoringTotalsDTO, int64, error) DB() *gorm.DB } @@ -74,6 +75,7 @@ type repportService struct { HppPerKandangRepo repportRepo.HppPerKandangRepository ProductionResultRepo repportRepo.ProductionResultRepository CustomerPaymentRepo repportRepo.CustomerPaymentRepository + BalanceMonitoringRepo repportRepo.BalanceMonitoringRepository CustomerRepo customerRepo.CustomerRepository StandardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository ProductionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository @@ -106,6 +108,7 @@ func NewRepportService( hppPerKandangRepo repportRepo.HppPerKandangRepository, productionResultRepo repportRepo.ProductionResultRepository, customerPaymentRepo repportRepo.CustomerPaymentRepository, + balanceMonitoringRepo repportRepo.BalanceMonitoringRepository, customerRepo customerRepo.CustomerRepository, standardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository, productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository, @@ -129,6 +132,7 @@ func NewRepportService( HppPerKandangRepo: hppPerKandangRepo, ProductionResultRepo: productionResultRepo, CustomerPaymentRepo: customerPaymentRepo, + BalanceMonitoringRepo: balanceMonitoringRepo, CustomerRepo: customerRepo, StandardGrowthDetailRepo: standardGrowthDetailRepo, ProductionStandardDetailRepo: productionStandardDetailRepo, @@ -2893,3 +2897,163 @@ func parseOptionalFloat64(raw string) (*float64, error) { return &value, nil } + +func (s *repportService) GetBalanceMonitoring(ctx *fiber.Ctx, params *validation.BalanceMonitoringQuery) ([]dto.BalanceMonitoringRowDTO, dto.BalanceMonitoringTotalsDTO, int64, error) { + if params.SortBy == "" { + params.SortBy = "customer" + } + if params.SortOrder == "" { + params.SortOrder = "asc" + } + if params.FilterBy == "" { + params.FilterBy = "sold_at" + } + if params.Page < 1 { + params.Page = 1 + } + if params.Limit < 1 { + params.Limit = 10 + } + + if err := s.Validate.Struct(params); err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + + locationScope, err := m.ResolveLocationScope(ctx, s.DB()) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + areaScope, err := m.ResolveAreaScope(ctx, s.DB()) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + if locationScope.Restrict { + params.AllowedLocationIDs = toInt64Slice(locationScope.IDs) + } + if areaScope.Restrict { + params.AllowedAreaIDs = toInt64Slice(areaScope.IDs) + } + + offset := (params.Page - 1) * params.Limit + + customerIDs, total, err := s.BalanceMonitoringRepo.GetCustomerIDsForBalanceMonitoring(ctx.Context(), offset, params.Limit, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + if len(customerIDs) == 0 { + emptyTotals, gtErr := s.computeBalanceMonitoringTotals(ctx.Context(), params) + if gtErr != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, gtErr + } + return []dto.BalanceMonitoringRowDTO{}, emptyTotals, total, nil + } + + saldoAwalLifetimeMap, err := s.BalanceMonitoringRepo.GetSaldoAwalLifetime(ctx.Context(), customerIDs) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + salesBeforeMap, err := s.BalanceMonitoringRepo.GetSalesTotalsBeforeDate(ctx.Context(), customerIDs, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + paymentBeforeMap, err := s.BalanceMonitoringRepo.GetPaymentTotalsBeforeDate(ctx.Context(), customerIDs, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + categoryMap, err := s.BalanceMonitoringRepo.GetSalesByCategoryInPeriod(ctx.Context(), customerIDs, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + paymentInPeriodMap, err := s.BalanceMonitoringRepo.GetPaymentTotalsInPeriod(ctx.Context(), customerIDs, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + agingMap, err := s.BalanceMonitoringRepo.GetAgingPerCustomer(ctx.Context(), customerIDs, params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + + customers, err := s.CustomerRepo.GetByIDs(ctx.Context(), customerIDs, func(db *gorm.DB) *gorm.DB { + return db.Preload("Pic") + }) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + customerMap := make(map[uint]entity.Customer, len(customers)) + for _, c := range customers { + customerMap[c.Id] = c + } + + result := make([]dto.BalanceMonitoringRowDTO, 0, len(customerIDs)) + for _, customerID := range customerIDs { + customer, ok := customerMap[customerID] + if !ok { + continue + } + + saldoAwal := saldoAwalLifetimeMap[customerID] + paymentBeforeMap[customerID] - salesBeforeMap[customerID] + + category := categoryMap[customerID] + ayam := dto.BalanceMonitoringAyamDTO{ + Ekor: category.AyamQty, + Kg: category.AyamKg, + Nominal: category.AyamNominal, + } + telur := dto.BalanceMonitoringTelurDTO{ + Butir: category.TelurQty, + Kg: category.TelurKg, + Nominal: category.TelurNominal, + } + trading := dto.BalanceMonitoringTradingDTO{ + Qty: category.TradingQty, + Kg: category.TradingKg, + Nominal: category.TradingNominal, + } + + pembayaran := paymentInPeriodMap[customerID] + aging := agingMap[customerID] + + row := dto.ToBalanceMonitoringRowDTO(customer, saldoAwal, ayam, telur, trading, pembayaran, aging.AgingMax, aging.AgingRataRata) + result = append(result, row) + } + + totals, err := s.computeBalanceMonitoringTotals(ctx.Context(), params) + if err != nil { + return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err + } + + return result, totals, total, nil +} + +func (s *repportService) computeBalanceMonitoringTotals(ctx context.Context, params *validation.BalanceMonitoringQuery) (dto.BalanceMonitoringTotalsDTO, error) { + grand, err := s.BalanceMonitoringRepo.GetGrandTotals(ctx, params) + if err != nil { + return dto.BalanceMonitoringTotalsDTO{}, err + } + + saldoAwal := grand.SaldoAwalLifetime + grand.PaymentBeforeStart - grand.SalesBeforeStart + saldoAkhir := saldoAwal + grand.PaymentInPeriod - (grand.AyamNominal + grand.TelurNominal + grand.TradingNominal) + + return dto.BalanceMonitoringTotalsDTO{ + SaldoAwal: saldoAwal, + PenjualanAyam: dto.BalanceMonitoringAyamDTO{ + Ekor: grand.AyamQty, + Kg: grand.AyamKg, + Nominal: grand.AyamNominal, + }, + PenjualanTelur: dto.BalanceMonitoringTelurDTO{ + Butir: grand.TelurQty, + Kg: grand.TelurKg, + Nominal: grand.TelurNominal, + }, + PenjualanTrading: dto.BalanceMonitoringTradingDTO{ + Qty: grand.TradingQty, + Kg: grand.TradingKg, + Nominal: grand.TradingNominal, + }, + Pembayaran: grand.PaymentInPeriod, + Aging: grand.AgingMax, + AgingRataRata: grand.AgingRataRata, + SaldoAkhir: saldoAkhir, + }, nil +} diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 0ef458e1..c2d06c12 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -116,3 +116,17 @@ type CustomerPaymentQuery struct { StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` } + +type BalanceMonitoringQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` + CustomerIDs []uint `query:"-" validate:"omitempty,dive,gt=0"` + SalesIDs []uint `query:"-" validate:"omitempty,dive,gt=0"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof=sold_at realized_at"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=customer"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + AllowedAreaIDs []int64 `query:"-"` + AllowedLocationIDs []int64 `query:"-"` +}