diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index c8e41f6..5950023 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -3,10 +3,8 @@ package processor import ( "context" "fmt" - "strings" "time" - "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) @@ -453,24 +451,45 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req } } - todayPromosi := getExpenseAmountByCategory(result.TodayExpenseByCategory, "promosi") - todayLainLain := getExpenseAmountByCategory(result.TodayExpenseByCategory, "lain") - todayTotalOps := todayPromosi + todayLainLain - todayGaji := getExpenseAmountByCategory(result.TodayExpenseByCategory, "gaji") + type categoryAmount struct { + Name string + TodayAmt float64 + MtdAmt float64 + } - mtdPromosi := getExpenseAmountByCategory(result.MtdExpenseByCategory, "promosi") - mtdLainLain := getExpenseAmountByCategory(result.MtdExpenseByCategory, "lain") - mtdTotalOps := mtdPromosi + mtdLainLain - mtdGaji := getExpenseAmountByCategory(result.MtdExpenseByCategory, "gaji") + categoryMap := make(map[string]*categoryAmount) + var categoryOrder []string + + for _, cat := range result.TodayExpenseByCategory { + name := cat.CategoryName + if _, exists := categoryMap[name]; !exists { + categoryMap[name] = &categoryAmount{Name: name} + categoryOrder = append(categoryOrder, name) + } + categoryMap[name].TodayAmt = cat.Amount + } + + for _, cat := range result.MtdExpenseByCategory { + name := cat.CategoryName + if _, exists := categoryMap[name]; !exists { + categoryMap[name] = &categoryAmount{Name: name} + categoryOrder = append(categoryOrder, name) + } + categoryMap[name].MtdAmt = cat.Amount + } + + var todayTotalOps float64 + var mtdTotalOps float64 + for _, cat := range categoryMap { + todayTotalOps += cat.TodayAmt + mtdTotalOps += cat.MtdAmt + } todayGrossProfit := result.TodayRevenue - result.TodayCost mtdGrossProfit := result.MtdRevenue - result.MtdCost - todayProfitBeforeGaji := todayGrossProfit - todayTotalOps - mtdProfitBeforeGaji := mtdGrossProfit - mtdTotalOps - - todayNetProfit := todayProfitBeforeGaji - todayGaji - mtdNetProfit := mtdProfitBeforeGaji - mtdGaji + todayNetProfit := todayGrossProfit - todayTotalOps + mtdNetProfit := mtdGrossProfit - mtdTotalOps todayPct := func(nominal float64) float64 { if result.TodayRevenue == 0 { @@ -485,6 +504,28 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req return (nominal / result.MtdRevenue) * 100 } + opsSubItems := make([]models.ProfitLossSummaryRow, 0, len(categoryOrder)+1) + for i, name := range categoryOrder { + cat := categoryMap[name] + opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ + ID: fmt.Sprintf("by_%s", slugify(name)), + Label: fmt.Sprintf("%d. %s", i+1, cat.Name), + TodayNominal: cat.TodayAmt, + TodayPct: todayPct(cat.TodayAmt), + MtdNominal: cat.MtdAmt, + MtdPct: mtdPct(cat.MtdAmt), + }) + } + opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ + ID: "total_biaya_ops", + Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", len(categoryOrder)), + IsBold: true, + TodayNominal: todayTotalOps, + TodayPct: todayPct(todayTotalOps), + MtdNominal: mtdTotalOps, + MtdPct: mtdPct(mtdTotalOps), + }) + mainSummary := []models.ProfitLossSummaryRow{ { ID: "total_omset", Label: "TOTAL OMSET", @@ -505,36 +546,10 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req ID: "biaya_ops", Label: "BIAYA OPS", TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps), - SubItems: []models.ProfitLossSummaryRow{ - { - ID: "by_promosi", Label: "1. By Promosi", - TodayNominal: todayPromosi, TodayPct: todayPct(todayPromosi), - MtdNominal: mtdPromosi, MtdPct: mtdPct(mtdPromosi), - }, - { - ID: "by_lain_lain", Label: "2. By Lain lain", - TodayNominal: todayLainLain, TodayPct: todayPct(todayLainLain), - MtdNominal: mtdLainLain, MtdPct: mtdPct(mtdLainLain), - }, - { - ID: "total_biaya_ops", Label: "Total Biaya OPS (4.1+4.2)", IsBold: true, - TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), - MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps), - }, - }, + SubItems: opsSubItems, }, { - ID: "laba_rugi_sblm_gaji", Label: "Laba/Rugi sblm Gaji (3-4)", - TodayNominal: todayProfitBeforeGaji, TodayPct: todayPct(todayProfitBeforeGaji), - MtdNominal: mtdProfitBeforeGaji, MtdPct: mtdPct(mtdProfitBeforeGaji), - }, - { - ID: "biaya_gaji", Label: "BIAYA GAJI", - TodayNominal: todayGaji, TodayPct: todayPct(todayGaji), - MtdNominal: mtdGaji, MtdPct: mtdPct(mtdGaji), - }, - { - ID: "laba_rugi", Label: "Laba/Rugi (5-6)", IsBold: true, + ID: "laba_rugi", Label: "Laba/Rugi Bersih (3-4)", IsBold: true, TodayNominal: todayNetProfit, TodayPct: todayPct(todayNetProfit), MtdNominal: mtdNetProfit, MtdPct: mtdPct(mtdNetProfit), }, @@ -577,11 +592,22 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req }, nil } -func getExpenseAmountByCategory(categories []entities.ExpenseCategoryTotal, keyword string) float64 { - for _, cat := range categories { - if strings.Contains(strings.ToLower(cat.CategoryName), keyword) { - return cat.Amount +func slugify(s string) string { + result := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z': + result = append(result, c) + case c >= 'A' && c <= 'Z': + result = append(result, c+32) + case c >= '0' && c <= '9': + result = append(result, c) + default: + if len(result) == 0 || result[len(result)-1] != '_' { + result = append(result, '_') + } } } - return 0 + return string(result) } diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 998831a..620c3db 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -165,3 +165,79 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t * require.NotEmpty(t, result.MainSummary) require.Equal(t, "total_omset", result.MainSummary[0].ID) } + +func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) { + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + profitLossResult: &entities.ProfitLossAnalytics{ + Summary: entities.ProfitLossSummary{ + TotalRevenue: 10000, + TotalCost: 4000, + }, + TodayRevenue: 10000, + TodayCost: 4000, + MtdRevenue: 20000, + MtdCost: 8000, + TodayExpenseByCategory: []entities.ExpenseCategoryTotal{ + {CategoryName: "Gaji", Amount: 1500}, + {CategoryName: "Promosi", Amount: 300}, + {CategoryName: "Sewa", Amount: 500}, + }, + MtdExpenseByCategory: []entities.ExpenseCategoryTotal{ + {CategoryName: "Gaji", Amount: 3000}, + {CategoryName: "Promosi", Amount: 600}, + {CategoryName: "Sewa", Amount: 1000}, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.NotNil(t, result) + + require.Len(t, result.MainSummary, 5) + + require.Equal(t, "total_omset", result.MainSummary[0].ID) + require.Equal(t, float64(10000), result.MainSummary[0].TodayNominal) + require.Equal(t, float64(20000), result.MainSummary[0].MtdNominal) + + require.Equal(t, "hpp", result.MainSummary[1].ID) + require.Equal(t, float64(4000), result.MainSummary[1].TodayNominal) + require.Equal(t, float64(8000), result.MainSummary[1].MtdNominal) + + require.Equal(t, "laba_kotor", result.MainSummary[2].ID) + require.Equal(t, float64(6000), result.MainSummary[2].TodayNominal) + require.Equal(t, float64(12000), result.MainSummary[2].MtdNominal) + + require.Equal(t, "biaya_ops", result.MainSummary[3].ID) + require.Equal(t, float64(2300), result.MainSummary[3].TodayNominal) + require.Equal(t, float64(4600), result.MainSummary[3].MtdNominal) + require.Len(t, result.MainSummary[3].SubItems, 4) // 3 categories + 1 total + + require.Equal(t, "by_gaji", result.MainSummary[3].SubItems[0].ID) + require.Equal(t, float64(1500), result.MainSummary[3].SubItems[0].TodayNominal) + require.Equal(t, float64(3000), result.MainSummary[3].SubItems[0].MtdNominal) + + require.Equal(t, "by_promosi", result.MainSummary[3].SubItems[1].ID) + require.Equal(t, float64(300), result.MainSummary[3].SubItems[1].TodayNominal) + require.Equal(t, float64(600), result.MainSummary[3].SubItems[1].MtdNominal) + + require.Equal(t, "by_sewa", result.MainSummary[3].SubItems[2].ID) + require.Equal(t, float64(500), result.MainSummary[3].SubItems[2].TodayNominal) + require.Equal(t, float64(1000), result.MainSummary[3].SubItems[2].MtdNominal) + + require.Equal(t, "total_biaya_ops", result.MainSummary[3].SubItems[3].ID) + require.True(t, result.MainSummary[3].SubItems[3].IsBold) + require.Equal(t, float64(2300), result.MainSummary[3].SubItems[3].TodayNominal) + require.Equal(t, float64(4600), result.MainSummary[3].SubItems[3].MtdNominal) + + require.Equal(t, "laba_rugi", result.MainSummary[4].ID) + require.Equal(t, float64(3700), result.MainSummary[4].TodayNominal) + require.Equal(t, float64(7400), result.MainSummary[4].MtdNominal) + require.True(t, result.MainSummary[4].IsBold) +}