Add expense analytics
This commit is contained in:
parent
b90a3cde4a
commit
094e8b2a47
@ -91,3 +91,55 @@ type ListExpenseResponse struct {
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsRequest struct {
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
DateFrom string `form:"date_from" validate:"required"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
Summary ExpenseAnalyticsSummary `json:"summary"`
|
||||
Data []ExpenseAnalyticsData `json:"data"`
|
||||
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
|
||||
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsSummary struct {
|
||||
TotalExpenses float64 `json:"total_expenses"`
|
||||
TotalExpenseCount int64 `json:"total_expense_count"`
|
||||
TotalTax float64 `json:"total_tax"`
|
||||
AverageExpenseValue float64 `json:"average_expense_value"`
|
||||
TotalCategories int64 `json:"total_categories"`
|
||||
TotalItems int64 `json:"total_items"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Expenses float64 `json:"expenses"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
Tax float64 `json:"tax"`
|
||||
Items int64 `json:"items"`
|
||||
Categories int64 `json:"categories"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsCategoryData struct {
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
ChartOfAccountName string `json:"chart_of_account_name"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsItemData struct {
|
||||
Item string `json:"item"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
@ -28,6 +28,46 @@ type Expense struct {
|
||||
Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type ExpenseAnalytics struct {
|
||||
Summary ExpenseAnalyticsSummary
|
||||
Data []ExpenseAnalyticsData
|
||||
CategoryData []ExpenseAnalyticsCategoryData
|
||||
ItemData []ExpenseAnalyticsItemData
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsSummary struct {
|
||||
TotalExpenses float64
|
||||
TotalExpenseCount int64
|
||||
TotalTax float64
|
||||
AverageExpenseValue float64
|
||||
TotalCategories int64
|
||||
TotalItems int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsData struct {
|
||||
Date time.Time
|
||||
Expenses float64
|
||||
ExpenseCount int64
|
||||
Tax float64
|
||||
Items int64
|
||||
Categories int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsCategoryData struct {
|
||||
ChartOfAccountID uuid.UUID
|
||||
ChartOfAccountName string
|
||||
TotalAmount float64
|
||||
ExpenseCount int64
|
||||
ItemCount int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsItemData struct {
|
||||
Item string
|
||||
TotalAmount float64
|
||||
ExpenseCount int64
|
||||
ItemCount int64
|
||||
}
|
||||
|
||||
func (e *Expense) BeforeCreate(tx *gorm.DB) error {
|
||||
if e.ID == uuid.Nil {
|
||||
e.ID = uuid.New()
|
||||
|
||||
@ -199,3 +199,31 @@ func (h *ExpenseHandler) ListExpenses(c *gin.Context) {
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::ListExpenses")
|
||||
}
|
||||
|
||||
func (h *ExpenseHandler) GetExpenseAnalytics(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ExpenseAnalyticsRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpenseAnalytics -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpenseAnalytics")
|
||||
return
|
||||
}
|
||||
|
||||
if contextInfo.OutletID != uuid.Nil {
|
||||
outletID := contextInfo.OutletID.String()
|
||||
req.OutletID = &outletID
|
||||
} else if outletID := c.Query("outlet_id"); outletID != "" {
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
|
||||
expenseResponse := h.expenseService.GetExpenseAnalytics(ctx, contextInfo, &req)
|
||||
if expenseResponse.HasErrors() {
|
||||
errorResp := expenseResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpenseAnalytics -> Failed to get expense analytics from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpenseAnalytics")
|
||||
}
|
||||
|
||||
@ -118,3 +118,56 @@ type ListExpenseResponse struct {
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsRequest struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
Summary ExpenseAnalyticsSummary `json:"summary"`
|
||||
Data []ExpenseAnalyticsData `json:"data"`
|
||||
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
|
||||
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsSummary struct {
|
||||
TotalExpenses float64 `json:"total_expenses"`
|
||||
TotalExpenseCount int64 `json:"total_expense_count"`
|
||||
TotalTax float64 `json:"total_tax"`
|
||||
AverageExpenseValue float64 `json:"average_expense_value"`
|
||||
TotalCategories int64 `json:"total_categories"`
|
||||
TotalItems int64 `json:"total_items"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Expenses float64 `json:"expenses"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
Tax float64 `json:"tax"`
|
||||
Items int64 `json:"items"`
|
||||
Categories int64 `json:"categories"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsCategoryData struct {
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
ChartOfAccountName string `json:"chart_of_account_name"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsItemData struct {
|
||||
Item string `json:"item"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -114,6 +114,138 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU
|
||||
return expenses, total, err
|
||||
}
|
||||
|
||||
func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error) {
|
||||
var summary entities.ExpenseAnalyticsSummary
|
||||
|
||||
summaryQuery := r.db.WithContext(ctx).
|
||||
Table("expenses e").
|
||||
Select(`
|
||||
COALESCE(SUM(e.total), 0) as total_expenses,
|
||||
COUNT(e.id) as total_expense_count,
|
||||
COALESCE(SUM(e.tax), 0) as total_tax,
|
||||
COALESCE(AVG(e.total), 0) as average_expense_value
|
||||
`).
|
||||
Where("e.organization_id = ?", organizationID).
|
||||
Where("e.status = ?", "approved").
|
||||
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
|
||||
if outletID != nil {
|
||||
summaryQuery = summaryQuery.Where("e.outlet_id = ?", *outletID)
|
||||
}
|
||||
if err := summaryQuery.Scan(&summary).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
countsQuery := r.db.WithContext(ctx).
|
||||
Table("expense_items ei").
|
||||
Select(`
|
||||
COUNT(ei.id) as total_items,
|
||||
COUNT(DISTINCT ei.chart_of_account_id) as total_categories
|
||||
`).
|
||||
Joins("JOIN expenses e ON ei.expense_id = e.id").
|
||||
Where("e.organization_id = ?", organizationID).
|
||||
Where("e.status = ?", "approved").
|
||||
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
|
||||
if outletID != nil {
|
||||
countsQuery = countsQuery.Where("e.outlet_id = ?", *outletID)
|
||||
}
|
||||
if err := countsQuery.Scan(&summary).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dateFormat := "DATE_TRUNC('day', e.transaction_date)"
|
||||
switch groupBy {
|
||||
case "hour":
|
||||
dateFormat = "DATE_TRUNC('hour', e.transaction_date)"
|
||||
case "week":
|
||||
dateFormat = "DATE_TRUNC('week', e.transaction_date)"
|
||||
case "month":
|
||||
dateFormat = "DATE_TRUNC('month', e.transaction_date)"
|
||||
}
|
||||
|
||||
var data []entities.ExpenseAnalyticsData
|
||||
dataQuery := r.db.WithContext(ctx).
|
||||
Table("expenses e").
|
||||
Select(`
|
||||
`+dateFormat+` as date,
|
||||
COALESCE(SUM(e.total), 0) as expenses,
|
||||
COUNT(e.id) as expense_count,
|
||||
COALESCE(SUM(e.tax), 0) as tax,
|
||||
COALESCE(SUM(item_counts.items), 0) as items,
|
||||
COALESCE(SUM(item_counts.categories), 0) as categories
|
||||
`).
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT chart_of_account_id) as categories
|
||||
FROM expense_items
|
||||
GROUP BY expense_id
|
||||
) item_counts ON item_counts.expense_id = e.id`).
|
||||
Where("e.organization_id = ?", organizationID).
|
||||
Where("e.status = ?", "approved").
|
||||
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
|
||||
Group(dateFormat).
|
||||
Order(dateFormat)
|
||||
if outletID != nil {
|
||||
dataQuery = dataQuery.Where("e.outlet_id = ?", *outletID)
|
||||
}
|
||||
if err := dataQuery.Scan(&data).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var categoryData []entities.ExpenseAnalyticsCategoryData
|
||||
categoryQuery := r.db.WithContext(ctx).
|
||||
Table("expense_items ei").
|
||||
Select(`
|
||||
COALESCE(parent_coa.id, coa.id) as chart_of_account_id,
|
||||
COALESCE(parent_coa.name, coa.name, 'Lain-lain') as chart_of_account_name,
|
||||
COALESCE(SUM(ei.amount), 0) as total_amount,
|
||||
COUNT(DISTINCT e.id) as expense_count,
|
||||
COUNT(ei.id) as item_count
|
||||
`).
|
||||
Joins("JOIN expenses e ON ei.expense_id = e.id").
|
||||
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
|
||||
Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id").
|
||||
Where("e.organization_id = ?", organizationID).
|
||||
Where("e.status = ?", "approved").
|
||||
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
|
||||
Group("COALESCE(parent_coa.id, coa.id), COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
|
||||
Order("total_amount DESC")
|
||||
if outletID != nil {
|
||||
categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID)
|
||||
}
|
||||
if err := categoryQuery.Scan(&categoryData).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var itemData []entities.ExpenseAnalyticsItemData
|
||||
itemQuery := r.db.WithContext(ctx).
|
||||
Table("expense_items ei").
|
||||
Select(`
|
||||
COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item,
|
||||
COALESCE(SUM(ei.amount), 0) as total_amount,
|
||||
COUNT(DISTINCT e.id) as expense_count,
|
||||
COUNT(ei.id) as item_count
|
||||
`).
|
||||
Joins("JOIN expenses e ON ei.expense_id = e.id").
|
||||
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
|
||||
Where("e.organization_id = ?", organizationID).
|
||||
Where("e.status = ?", "approved").
|
||||
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
|
||||
Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)").
|
||||
Order("total_amount DESC")
|
||||
if outletID != nil {
|
||||
itemQuery = itemQuery.Where("e.outlet_id = ?", *outletID)
|
||||
}
|
||||
if err := itemQuery.Scan(&itemData).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &entities.ExpenseAnalytics{
|
||||
Summary: summary,
|
||||
Data: data,
|
||||
CategoryData: categoryData,
|
||||
ItemData: itemData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *ExpenseRepositoryImpl) CreateItem(ctx context.Context, item *entities.ExpenseItem) error {
|
||||
return r.db.WithContext(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
@ -451,6 +451,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
{
|
||||
expenses.POST("", r.expenseHandler.CreateExpense)
|
||||
expenses.GET("", r.expenseHandler.ListExpenses)
|
||||
expenses.GET("/analytics", r.expenseHandler.GetExpenseAnalytics)
|
||||
expenses.GET("/:id", r.expenseHandler.GetExpense)
|
||||
expenses.PUT("/:id", r.expenseHandler.UpdateExpense)
|
||||
expenses.DELETE("/:id", r.expenseHandler.DeleteExpense)
|
||||
|
||||
@ -19,6 +19,7 @@ type ExpenseService interface {
|
||||
DeleteExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
|
||||
GetExpenseByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
|
||||
ListExpenses(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListExpenseRequest) *contract.Response
|
||||
GetExpenseAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ExpenseAnalyticsRequest) *contract.Response
|
||||
}
|
||||
|
||||
type ExpenseServiceImpl struct {
|
||||
@ -126,3 +127,24 @@ func (s *ExpenseServiceImpl) ListExpenses(ctx context.Context, apctx *appcontext
|
||||
|
||||
return contract.BuildSuccessResponse(response)
|
||||
}
|
||||
|
||||
func (s *ExpenseServiceImpl) GetExpenseAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ExpenseAnalyticsRequest) *contract.Response {
|
||||
modelReq, err := transformer.ExpenseAnalyticsRequestToModel(req)
|
||||
if err != nil {
|
||||
errorResp := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.ExpenseServiceEntity, err.Error())
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||
}
|
||||
|
||||
modelReq.OrganizationID = apctx.OrganizationID
|
||||
if apctx.OutletID != uuid.Nil {
|
||||
modelReq.OutletID = &apctx.OutletID
|
||||
}
|
||||
|
||||
response, err := s.expenseProcessor.GetExpenseAnalytics(ctx, modelReq)
|
||||
if err != nil {
|
||||
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error())
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||
}
|
||||
|
||||
return contract.BuildSuccessResponse(transformer.ExpenseAnalyticsModelToContract(response))
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package transformer
|
||||
import (
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.CreateExpenseRequest {
|
||||
@ -134,3 +135,81 @@ func ExpenseModelResponsesToResponses(expenses []*models.ExpenseResponse) []cont
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
func ExpenseAnalyticsRequestToModel(req *contract.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsRequest, error) {
|
||||
dateFrom, dateTo, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modelReq := &models.ExpenseAnalyticsRequest{
|
||||
OutletID: parseOutletID(req.OutletID),
|
||||
GroupBy: req.GroupBy,
|
||||
}
|
||||
if dateFrom != nil {
|
||||
modelReq.DateFrom = *dateFrom
|
||||
}
|
||||
if dateTo != nil {
|
||||
modelReq.DateTo = *dateTo
|
||||
}
|
||||
|
||||
return modelReq, nil
|
||||
}
|
||||
|
||||
func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *contract.ExpenseAnalyticsResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
data := make([]contract.ExpenseAnalyticsData, len(resp.Data))
|
||||
for i, item := range resp.Data {
|
||||
data[i] = contract.ExpenseAnalyticsData{
|
||||
Date: item.Date,
|
||||
Expenses: item.Expenses,
|
||||
ExpenseCount: item.ExpenseCount,
|
||||
Tax: item.Tax,
|
||||
Items: item.Items,
|
||||
Categories: item.Categories,
|
||||
}
|
||||
}
|
||||
|
||||
categoryData := make([]contract.ExpenseAnalyticsCategoryData, len(resp.CategoryData))
|
||||
for i, item := range resp.CategoryData {
|
||||
categoryData[i] = contract.ExpenseAnalyticsCategoryData{
|
||||
ChartOfAccountID: item.ChartOfAccountID,
|
||||
ChartOfAccountName: item.ChartOfAccountName,
|
||||
TotalAmount: item.TotalAmount,
|
||||
ExpenseCount: item.ExpenseCount,
|
||||
ItemCount: item.ItemCount,
|
||||
}
|
||||
}
|
||||
|
||||
itemData := make([]contract.ExpenseAnalyticsItemData, len(resp.ItemData))
|
||||
for i, item := range resp.ItemData {
|
||||
itemData[i] = contract.ExpenseAnalyticsItemData{
|
||||
Item: item.Item,
|
||||
TotalAmount: item.TotalAmount,
|
||||
ExpenseCount: item.ExpenseCount,
|
||||
ItemCount: item.ItemCount,
|
||||
}
|
||||
}
|
||||
|
||||
return &contract.ExpenseAnalyticsResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
DateFrom: resp.DateFrom,
|
||||
DateTo: resp.DateTo,
|
||||
GroupBy: resp.GroupBy,
|
||||
Summary: contract.ExpenseAnalyticsSummary{
|
||||
TotalExpenses: resp.Summary.TotalExpenses,
|
||||
TotalExpenseCount: resp.Summary.TotalExpenseCount,
|
||||
TotalTax: resp.Summary.TotalTax,
|
||||
AverageExpenseValue: resp.Summary.AverageExpenseValue,
|
||||
TotalCategories: resp.Summary.TotalCategories,
|
||||
TotalItems: resp.Summary.TotalItems,
|
||||
},
|
||||
Data: data,
|
||||
CategoryData: categoryData,
|
||||
ItemData: itemData,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user