Add expense analytics
This commit is contained in:
parent
b90a3cde4a
commit
094e8b2a47
@ -91,3 +91,55 @@ type ListExpenseResponse struct {
|
|||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
TotalPages int `json:"total_pages"`
|
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"`
|
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 {
|
func (e *Expense) BeforeCreate(tx *gorm.DB) error {
|
||||||
if e.ID == uuid.Nil {
|
if e.ID == uuid.Nil {
|
||||||
e.ID = uuid.New()
|
e.ID = uuid.New()
|
||||||
|
|||||||
@ -199,3 +199,31 @@ func (h *ExpenseHandler) ListExpenses(c *gin.Context) {
|
|||||||
|
|
||||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::ListExpenses")
|
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"`
|
Limit int `json:"limit"`
|
||||||
TotalPages int `json:"total_pages"`
|
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) {
|
func (expenseRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
|
||||||
return nil, 0, nil
|
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) CreateItem(context.Context, *entities.ExpenseItem) error { return nil }
|
||||||
func (expenseRepositoryStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) 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
|
DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error
|
||||||
GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, 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)
|
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 {
|
type ExpenseProcessorImpl struct {
|
||||||
@ -221,3 +222,70 @@ func (p *ExpenseProcessorImpl) ListExpenses(ctx context.Context, organizationID
|
|||||||
|
|
||||||
return expenseResponses, totalPages, nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
@ -14,6 +15,7 @@ import (
|
|||||||
type expenseRepositoryCaptureStub struct {
|
type expenseRepositoryCaptureStub struct {
|
||||||
createdExpense *entities.Expense
|
createdExpense *entities.Expense
|
||||||
createdItems []*entities.ExpenseItem
|
createdItems []*entities.ExpenseItem
|
||||||
|
analytics *entities.ExpenseAnalytics
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error {
|
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) {
|
func (*expenseRepositoryCaptureStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
|
||||||
return nil, 0, nil
|
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 {
|
func (s *expenseRepositoryCaptureStub) CreateItem(_ context.Context, item *entities.ExpenseItem) error {
|
||||||
if item.ID == uuid.Nil {
|
if item.ID == uuid.Nil {
|
||||||
item.ID = uuid.New()
|
item.ID = uuid.New()
|
||||||
@ -134,3 +139,68 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
|
|||||||
require.Equal(t, "approved", repo.createdExpense.Status)
|
require.Equal(t, "approved", repo.createdExpense.Status)
|
||||||
require.Equal(t, "approved", resp.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 (
|
import (
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@ -14,6 +15,7 @@ type ExpenseRepository interface {
|
|||||||
Update(ctx context.Context, expense *entities.Expense) error
|
Update(ctx context.Context, expense *entities.Expense) error
|
||||||
Delete(ctx context.Context, id uuid.UUID) 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)
|
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
|
CreateItem(ctx context.Context, item *entities.ExpenseItem) error
|
||||||
DeleteItemsByExpenseID(ctx context.Context, expenseID uuid.UUID) 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
|
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 {
|
func (r *ExpenseRepositoryImpl) CreateItem(ctx context.Context, item *entities.ExpenseItem) error {
|
||||||
return r.db.WithContext(ctx).Create(item).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.POST("", r.expenseHandler.CreateExpense)
|
||||||
expenses.GET("", r.expenseHandler.ListExpenses)
|
expenses.GET("", r.expenseHandler.ListExpenses)
|
||||||
|
expenses.GET("/analytics", r.expenseHandler.GetExpenseAnalytics)
|
||||||
expenses.GET("/:id", r.expenseHandler.GetExpense)
|
expenses.GET("/:id", r.expenseHandler.GetExpense)
|
||||||
expenses.PUT("/:id", r.expenseHandler.UpdateExpense)
|
expenses.PUT("/:id", r.expenseHandler.UpdateExpense)
|
||||||
expenses.DELETE("/:id", r.expenseHandler.DeleteExpense)
|
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
|
DeleteExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
|
||||||
GetExpenseByID(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
|
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 {
|
type ExpenseServiceImpl struct {
|
||||||
@ -126,3 +127,24 @@ func (s *ExpenseServiceImpl) ListExpenses(ctx context.Context, apctx *appcontext
|
|||||||
|
|
||||||
return contract.BuildSuccessResponse(response)
|
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 (
|
import (
|
||||||
"apskel-pos-be/internal/contract"
|
"apskel-pos-be/internal/contract"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
|
"apskel-pos-be/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.CreateExpenseRequest {
|
func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.CreateExpenseRequest {
|
||||||
@ -134,3 +135,81 @@ func ExpenseModelResponsesToResponses(expenses []*models.ExpenseResponse) []cont
|
|||||||
}
|
}
|
||||||
return responses
|
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