Add expense analytics
This commit is contained in:
@@ -61,6 +61,9 @@ func (expenseRepositoryStub) Delete(context.Context, uuid.UUID) error {
|
||||
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 }
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ type ExpenseProcessor interface {
|
||||
DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error
|
||||
GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error)
|
||||
ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error)
|
||||
GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error)
|
||||
}
|
||||
|
||||
type ExpenseProcessorImpl struct {
|
||||
@@ -221,3 +222,70 @@ func (p *ExpenseProcessorImpl) ListExpenses(ctx context.Context, organizationID
|
||||
|
||||
return expenseResponses, totalPages, nil
|
||||
}
|
||||
|
||||
func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error) {
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||
}
|
||||
|
||||
if req.GroupBy == "" {
|
||||
req.GroupBy = "day"
|
||||
}
|
||||
|
||||
result, err := p.expenseRepo.GetAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get expense analytics: %w", err)
|
||||
}
|
||||
|
||||
data := make([]models.ExpenseAnalyticsData, len(result.Data))
|
||||
for i, item := range result.Data {
|
||||
data[i] = models.ExpenseAnalyticsData{
|
||||
Date: item.Date,
|
||||
Expenses: item.Expenses,
|
||||
ExpenseCount: item.ExpenseCount,
|
||||
Tax: item.Tax,
|
||||
Items: item.Items,
|
||||
Categories: item.Categories,
|
||||
}
|
||||
}
|
||||
|
||||
categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData))
|
||||
for i, item := range result.CategoryData {
|
||||
categoryData[i] = models.ExpenseAnalyticsCategoryData{
|
||||
ChartOfAccountID: item.ChartOfAccountID,
|
||||
ChartOfAccountName: item.ChartOfAccountName,
|
||||
TotalAmount: item.TotalAmount,
|
||||
ExpenseCount: item.ExpenseCount,
|
||||
ItemCount: item.ItemCount,
|
||||
}
|
||||
}
|
||||
|
||||
itemData := make([]models.ExpenseAnalyticsItemData, len(result.ItemData))
|
||||
for i, item := range result.ItemData {
|
||||
itemData[i] = models.ExpenseAnalyticsItemData{
|
||||
Item: item.Item,
|
||||
TotalAmount: item.TotalAmount,
|
||||
ExpenseCount: item.ExpenseCount,
|
||||
ItemCount: item.ItemCount,
|
||||
}
|
||||
}
|
||||
|
||||
return &models.ExpenseAnalyticsResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
GroupBy: req.GroupBy,
|
||||
Summary: models.ExpenseAnalyticsSummary{
|
||||
TotalExpenses: result.Summary.TotalExpenses,
|
||||
TotalExpenseCount: result.Summary.TotalExpenseCount,
|
||||
TotalTax: result.Summary.TotalTax,
|
||||
AverageExpenseValue: result.Summary.AverageExpenseValue,
|
||||
TotalCategories: result.Summary.TotalCategories,
|
||||
TotalItems: result.Summary.TotalItems,
|
||||
},
|
||||
Data: data,
|
||||
CategoryData: categoryData,
|
||||
ItemData: itemData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package processor
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
type expenseRepositoryCaptureStub struct {
|
||||
createdExpense *entities.Expense
|
||||
createdItems []*entities.ExpenseItem
|
||||
analytics *entities.ExpenseAnalytics
|
||||
}
|
||||
|
||||
func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error {
|
||||
@@ -44,6 +46,9 @@ func (*expenseRepositoryCaptureStub) Delete(context.Context, uuid.UUID) error
|
||||
func (*expenseRepositoryCaptureStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (s *expenseRepositoryCaptureStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) {
|
||||
return s.analytics, nil
|
||||
}
|
||||
func (s *expenseRepositoryCaptureStub) CreateItem(_ context.Context, item *entities.ExpenseItem) error {
|
||||
if item.ID == uuid.Nil {
|
||||
item.ID = uuid.New()
|
||||
@@ -134,3 +139,68 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
|
||||
require.Equal(t, "approved", repo.createdExpense.Status)
|
||||
require.Equal(t, "approved", resp.Status)
|
||||
}
|
||||
|
||||
func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) {
|
||||
coaID := uuid.New()
|
||||
outletID := uuid.New()
|
||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
repo := &expenseRepositoryCaptureStub{
|
||||
analytics: &entities.ExpenseAnalytics{
|
||||
Summary: entities.ExpenseAnalyticsSummary{
|
||||
TotalExpenses: 100000,
|
||||
TotalExpenseCount: 2,
|
||||
TotalTax: 10000,
|
||||
AverageExpenseValue: 50000,
|
||||
TotalCategories: 1,
|
||||
TotalItems: 2,
|
||||
},
|
||||
Data: []entities.ExpenseAnalyticsData{
|
||||
{
|
||||
Date: now,
|
||||
Expenses: 100000,
|
||||
ExpenseCount: 2,
|
||||
Tax: 10000,
|
||||
Items: 2,
|
||||
Categories: 1,
|
||||
},
|
||||
},
|
||||
CategoryData: []entities.ExpenseAnalyticsCategoryData{
|
||||
{
|
||||
ChartOfAccountID: coaID,
|
||||
ChartOfAccountName: "Operational",
|
||||
TotalAmount: 100000,
|
||||
ExpenseCount: 2,
|
||||
ItemCount: 2,
|
||||
},
|
||||
},
|
||||
ItemData: []entities.ExpenseAnalyticsItemData{
|
||||
{
|
||||
Item: "Cleaning supplies",
|
||||
TotalAmount: 100000,
|
||||
ExpenseCount: 2,
|
||||
ItemCount: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
p := NewExpenseProcessorImpl(repo)
|
||||
|
||||
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
OutletID: &outletID,
|
||||
DateFrom: now,
|
||||
DateTo: now,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, "day", resp.GroupBy)
|
||||
require.Equal(t, &outletID, resp.OutletID)
|
||||
require.Equal(t, float64(100000), resp.Summary.TotalExpenses)
|
||||
require.Len(t, resp.Data, 1)
|
||||
require.Equal(t, int64(2), resp.Data[0].ExpenseCount)
|
||||
require.Len(t, resp.CategoryData, 1)
|
||||
require.Equal(t, coaID, resp.CategoryData[0].ChartOfAccountID)
|
||||
require.Len(t, resp.ItemData, 1)
|
||||
require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package processor
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -14,6 +15,7 @@ type ExpenseRepository interface {
|
||||
Update(ctx context.Context, expense *entities.Expense) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.Expense, int64, error)
|
||||
GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error)
|
||||
CreateItem(ctx context.Context, item *entities.ExpenseItem) error
|
||||
DeleteItemsByExpenseID(ctx context.Context, expenseID uuid.UUID) error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user