package processor import ( "context" "testing" "time" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "github.com/google/uuid" "github.com/stretchr/testify/require" ) type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics profitLossResult *entities.ProfitLossAnalytics exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics bankBalances []entities.ExclusiveSummaryBankBalance profitLossGroup string exclusiveSummaryCalls int exclusiveSummaryFrom []time.Time exclusiveSummaryTo []time.Time } func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { return nil, nil } func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) { return nil, nil } func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) { return s.purchasingResult, nil } func (analyticsRepositoryStub) GetProductAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, int) ([]*entities.ProductAnalytics, error) { return nil, nil } func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerCategory, error) { return nil, nil } func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) { return nil, nil } func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, _, _ time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) { s.profitLossGroup = groupBy return s.profitLossResult, nil } func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) { s.exclusiveSummaryFrom = append(s.exclusiveSummaryFrom, dateFrom) s.exclusiveSummaryTo = append(s.exclusiveSummaryTo, dateTo) if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) { result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls] s.exclusiveSummaryCalls++ return result, nil } s.exclusiveSummaryCalls++ return &entities.ExclusiveSummaryAnalytics{}, nil } func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) { return s.bankBalances, nil } func (analyticsRepositoryStub) GetOutletName(context.Context, uuid.UUID, uuid.UUID) (string, error) { return "", nil } type expenseRepositoryStub struct{} func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil } func (expenseRepositoryStub) GetByID(context.Context, uuid.UUID) (*entities.Expense, error) { return nil, nil } func (expenseRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.Expense, error) { return nil, nil } func (expenseRepositoryStub) Update(context.Context, *entities.Expense) error { return nil } func (expenseRepositoryStub) Delete(context.Context, uuid.UUID) error { return nil } func (expenseRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) { return nil, 0, nil } func (expenseRepositoryStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) { return nil, nil } func (expenseRepositoryStub) CreateItem(context.Context, *entities.ExpenseItem) error { return nil } func (expenseRepositoryStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error { return nil } func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) { outletID := uuid.New() outletName := "Main Outlet" now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ purchasingResult: &entities.PurchasingAnalytics{ OutletName: &outletName, Summary: entities.PurchasingSummary{ TotalPurchases: 300, RawMaterialPurchases: 125, ExpensePurchases: 175, TotalPurchaseOrders: 3, RawMaterialPurchaseOrders: 1, ExpenseCount: 2, }, Data: []entities.PurchasingAnalyticsData{ { Date: now, Purchases: 300, RawMaterialPurchases: 125, ExpensePurchases: 175, PurchaseOrders: 3, RawMaterialPurchaseOrders: 1, ExpenseCount: 2, }, }, }, }, expenseRepositoryStub{}) result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ OrganizationID: uuid.New(), OutletID: &outletID, DateFrom: now, DateTo: now, }) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, &outletID, result.OutletID) require.NotNil(t, result.OutletName) require.Equal(t, outletName, *result.OutletName) require.Equal(t, float64(300), result.Summary.TotalPurchases) require.Equal(t, float64(125), result.Summary.RawMaterialPurchases) require.Equal(t, float64(175), result.Summary.ExpensePurchases) require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders) require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders) require.Equal(t, int64(2), result.Summary.ExpenseCount) require.Len(t, result.Data, 1) require.Equal(t, float64(300), result.Data[0].Purchases) require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases) require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } func TestAnalyticsProcessorGetPurchasingAnalyticsMapsOutletData(t *testing.T) { outletID := uuid.New() now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ purchasingResult: &entities.PurchasingAnalytics{ Summary: entities.PurchasingSummary{ TotalPurchases: 500, }, OutletData: []entities.PurchasingOutletData{ { OutletID: &outletID, OutletName: "Outlet A", Purchases: 500, RawMaterialPurchases: 350, ExpensePurchases: 150, PurchaseOrders: 4, RawMaterialPurchaseOrders: 3, ExpenseCount: 2, Quantity: 10, Ingredients: 5, Vendors: 2, }, }, }, }, expenseRepositoryStub{}) result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ OrganizationID: uuid.New(), DateFrom: now, DateTo: now, GroupBy: "outlet_id", }) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "outlet_id", result.GroupBy) require.Empty(t, result.Data) require.Len(t, result.OutletData, 1) require.Equal(t, &outletID, result.OutletData[0].OutletID) require.Equal(t, "Outlet A", result.OutletData[0].OutletName) require.Equal(t, float64(500), result.OutletData[0].Purchases) require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases) require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases) require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders) } func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { productID := uuid.New() categoryID := uuid.New() now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ profitLossResult: &entities.ProfitLossAnalytics{ Summary: entities.ProfitLossSummary{ TotalRevenue: 1000, TotalCost: 400, GrossProfit: 600, GrossProfitMargin: 60, TotalTax: 50, TotalDiscount: 25, NetProfit: 575, NetProfitMargin: 57.5, TotalOrders: 10, AverageProfit: 57.5, ProfitabilityRatio: 150, }, Data: []entities.ProfitLossData{ { Date: now, Revenue: 1000, Cost: 400, GrossProfit: 600, GrossProfitMargin: 60, Tax: 50, Discount: 25, NetProfit: 575, NetProfitMargin: 57.5, Orders: 10, }, }, ProductData: []entities.ProductProfitData{ { ProductID: productID, ProductName: "Nasi", CategoryID: categoryID, CategoryName: "Food", QuantitySold: 5, Revenue: 500, Cost: 200, GrossProfit: 300, GrossProfitMargin: 60, AveragePrice: 100, AverageCost: 40, ProfitPerUnit: 60, }, }, TodayRevenue: 1000, TodayCost: 400, MtdRevenue: 2000, MtdCost: 800, }, }, 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.Equal(t, "day", result.GroupBy) require.Equal(t, float64(1000), result.Summary.TotalRevenue) require.Len(t, result.Data, 1) require.Equal(t, float64(575), result.Data[0].NetProfit) require.Len(t, result.ProductData, 1) require.Equal(t, productID, result.ProductData[0].ProductID) 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, 7) 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(800), result.MainSummary[3].TodayNominal) require.Equal(t, float64(1600), result.MainSummary[3].MtdNominal) require.Len(t, result.MainSummary[3].SubItems, 3) // 2 operational categories + 1 total require.Equal(t, "by_promosi", result.MainSummary[3].SubItems[0].ID) require.Equal(t, float64(300), result.MainSummary[3].SubItems[0].TodayNominal) require.Equal(t, float64(600), result.MainSummary[3].SubItems[0].MtdNominal) require.Equal(t, "by_sewa", result.MainSummary[3].SubItems[1].ID) require.Equal(t, float64(500), result.MainSummary[3].SubItems[1].TodayNominal) require.Equal(t, float64(1000), result.MainSummary[3].SubItems[1].MtdNominal) require.Equal(t, "total_biaya_ops", result.MainSummary[3].SubItems[2].ID) require.True(t, result.MainSummary[3].SubItems[2].IsBold) require.Equal(t, float64(800), result.MainSummary[3].SubItems[2].TodayNominal) require.Equal(t, float64(1600), result.MainSummary[3].SubItems[2].MtdNominal) require.Equal(t, "laba_rugi_sblm_gaji", result.MainSummary[4].ID) require.Equal(t, float64(5200), result.MainSummary[4].TodayNominal) require.Equal(t, float64(10400), result.MainSummary[4].MtdNominal) require.Equal(t, "biaya_gaji", result.MainSummary[5].ID) require.Equal(t, float64(1500), result.MainSummary[5].TodayNominal) require.Equal(t, float64(3000), result.MainSummary[5].MtdNominal) require.Equal(t, "laba_rugi", result.MainSummary[6].ID) require.Equal(t, float64(3700), result.MainSummary[6].TodayNominal) require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal) require.True(t, result.MainSummary[6].IsBold) } func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) { now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC) processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{ { SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ {CategoryCode: "RAW", CategoryName: "Raw", Amount: 400}, }, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ {CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250}, {CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100}, }, DailySummary: []entities.ExclusiveSummaryDailySummary{ {Date: now, TransactionCount: 3, TotalCost: 750}, }, DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{ {Date: now, CategoryCode: "RAW", CategoryName: "Raw", Description: "beras", Amount: 400, Source: "purchase_order"}, {Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "gaji karyawan", Amount: 200, Source: "purchase_order"}, {Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "DW", Amount: 50, Source: "purchase_order"}, {Date: now, CategoryCode: "OPS", CategoryName: "Operasional", Description: "atk", Amount: 100, Source: "purchase_order"}, }, }, }, }, expenseRepositoryStub{}) result, err := processor.GetExclusiveSummaryPeriod(context.Background(), &models.ExclusiveSummaryPeriodRequest{ OrganizationID: uuid.New(), DateFrom: now, DateTo: now, ExcludeGajiStaffFromReimburse: true, }) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, float64(1000), result.Summary.Sales) require.Equal(t, float64(400), result.Summary.HPP) require.Equal(t, float64(600), result.Summary.GrossProfit) require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal) require.Equal(t, float64(750), result.Summary.TotalCost) require.Equal(t, float64(250), result.Summary.NetProfit) require.Equal(t, float64(250), result.Summary.SalaryTotal) require.Equal(t, float64(50), result.Summary.SalaryDW) require.Equal(t, float64(200), result.Summary.SalaryStaff) require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses) require.Equal(t, float64(200), result.Reimburse.ExcludedSalaryStaff) require.Equal(t, float64(550), result.Reimburse.TotalReimburse) require.Len(t, result.HPPBreakdown, 1) require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage) require.Len(t, result.DailySummary, 1) require.Len(t, result.DailyTransactions, 4) } func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) { location, err := time.LoadLocation("Asia/Jakarta") require.NoError(t, err) month := time.Date(2026, 5, 1, 0, 0, 0, 0, location) openingBalance := 5000000.0 closingBalance := 5000000.0 notes := "Main cash account for daily transactions" stub := &analyticsRepositoryStub{ exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{ {SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}}, {SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}}, {SalesTotal: 200, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 80}}}, {SalesTotal: 300, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 120}}}, {SalesTotal: 400, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 160}}}, {SalesTotal: 500, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 200}}}, }, bankBalances: []entities.ExclusiveSummaryBankBalance{ {Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: ¬es}, }, } processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{}) result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{ OrganizationID: uuid.New(), Month: month, }) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "2026-05", result.Month) require.Equal(t, float64(1000), result.Summary.TotalSales) require.Equal(t, float64(400), result.Summary.HPP) require.Equal(t, float64(500), result.Summary.NetProfit) require.InDelta(t, float64(50), result.Summary.NetProfitMargin, 0.0001) require.Len(t, result.Periods, 5) require.Equal(t, "1 - 3 Mei", result.Periods[0].Label) require.Equal(t, "25 - 31 Mei", result.Periods[4].Label) require.Len(t, result.BankBalance, 1) require.Equal(t, "Cash and Bank", result.BankBalance[0].Bank) require.NotNil(t, result.BankBalance[0].OpeningBalance) require.Equal(t, openingBalance, *result.BankBalance[0].OpeningBalance) require.NotNil(t, result.BankBalance[0].ClosingBalance) require.Equal(t, closingBalance, *result.BankBalance[0].ClosingBalance) require.Nil(t, result.BankBalance[0].IncomingMutation) require.Nil(t, result.BankBalance[0].OutgoingMutation) require.NotNil(t, result.BankBalance[0].Notes) require.Equal(t, notes, *result.BankBalance[0].Notes) require.Equal(t, 6, stub.exclusiveSummaryCalls) } func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *testing.T) { location, err := time.LoadLocation("Asia/Jakarta") require.NoError(t, err) dateTo := time.Date(2026, 6, 18, 23, 59, 59, int(time.Second-time.Nanosecond), location) stub := &analyticsRepositoryStub{ exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{ { SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ {CategoryCode: "RAW", CategoryName: "Raw Material", Amount: 400}, }, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ {CategoryCode: "OPS", CategoryName: "Operational", Amount: 100}, }, DailySummary: []entities.ExclusiveSummaryDailySummary{ {Date: dateTo, TransactionCount: 2, TotalCost: 500}, }, DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{ {Date: dateTo, CategoryCode: "RAW", CategoryName: "Raw Material", Description: "beras", Amount: 400, Source: "purchase_order"}, {Date: dateTo, CategoryCode: "OPS", CategoryName: "Operational", Description: "atk", Amount: 100, Source: "expense"}, }, }, }, } processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{}) result, err := processor.GetExclusiveSummaryMTD(context.Background(), &models.ExclusiveSummaryMTDRequest{ OrganizationID: uuid.New(), DateTo: dateTo, }) require.NoError(t, err) require.NotNil(t, result) require.Len(t, stub.exclusiveSummaryFrom, 1) require.Equal(t, time.Date(2026, 6, 1, 0, 0, 0, 0, location), stub.exclusiveSummaryFrom[0]) require.Equal(t, dateTo, stub.exclusiveSummaryTo[0]) require.Equal(t, stub.exclusiveSummaryFrom[0], result.Period.DateFrom) require.Equal(t, dateTo, result.Period.DateTo) require.Equal(t, float64(1000), result.Summary.Sales) require.Equal(t, float64(400), result.Summary.HPP) require.Equal(t, float64(500), result.Summary.TotalCost) require.Equal(t, float64(500), result.Summary.NetProfit) require.Len(t, result.HPPBreakdown, 1) require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage) require.Len(t, result.OperationalExpenseBreakdown, 1) require.Len(t, result.DailySummary, 1) require.Len(t, result.DailyTransactions, 2) }