diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index ab091cd..817ed55 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -113,6 +113,33 @@ type ProductAnalyticsData struct { OrderCount int64 `json:"order_count"` } +// ProductAnalyticsPerCategoryRequest represents the request for product analytics per category +type ProductAnalyticsPerCategoryRequest struct { + OrganizationID uuid.UUID + OutletID *uuid.UUID `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` +} + +// ProductAnalyticsPerCategoryResponse represents the response for product analytics per category +type ProductAnalyticsPerCategoryResponse 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"` + Data []ProductAnalyticsPerCategoryData `json:"data"` +} + +// ProductAnalyticsPerCategoryData represents individual category analytics data +type ProductAnalyticsPerCategoryData struct { + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` +} + // DashboardAnalyticsRequest represents the request for dashboard analytics type DashboardAnalyticsRequest struct { OrganizationID uuid.UUID diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 30f3b9b..75ae947 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -39,6 +39,16 @@ type ProductAnalytics struct { OrderCount int64 `json:"order_count"` } +// ProductAnalyticsPerCategory represents product analytics data grouped by category +type ProductAnalyticsPerCategory struct { + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` +} + // DashboardOverview represents dashboard overview data type DashboardOverview struct { TotalSales float64 `json:"total_sales"` diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go index 69617d6..f7a3e04 100644 --- a/internal/handler/analytics_handler.go +++ b/internal/handler/analytics_handler.go @@ -97,6 +97,30 @@ func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalytics") } +func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ProductAnalyticsPerCategoryRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProductAnalyticsPerCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerCategory") + return + } + + req.OrganizationID = contextInfo.OrganizationID + req.OutletID = &contextInfo.OutletID + modelReq := transformer.ProductAnalyticsPerCategoryContractToModel(&req) + + response, err := h.analyticsService.GetProductAnalyticsPerCategory(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetProductAnalyticsPerCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerCategory") + return + } + + contractResp := transformer.ProductAnalyticsPerCategoryModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerCategory") +} + func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) { ctx := c.Request.Context() contextInfo := appcontext.FromGinContext(ctx) diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 94443a0..f0e865d 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -117,6 +117,33 @@ type ProductAnalyticsData struct { OrderCount int64 `json:"order_count"` } +// ProductAnalyticsPerCategoryRequest represents the request for product analytics per category +type ProductAnalyticsPerCategoryRequest struct { + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` +} + +// ProductAnalyticsPerCategoryResponse represents the response for product analytics per category +type ProductAnalyticsPerCategoryResponse 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"` + Data []ProductAnalyticsPerCategoryData `json:"data"` +} + +// ProductAnalyticsPerCategoryData represents individual category analytics data +type ProductAnalyticsPerCategoryData struct { + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` +} + // DashboardAnalyticsRequest represents the request for dashboard analytics type DashboardAnalyticsRequest struct { OrganizationID uuid.UUID `validate:"required"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index b7e21c6..bf121c0 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -13,6 +13,7 @@ type AnalyticsProcessor interface { GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) + GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) } @@ -204,6 +205,40 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m }, nil } +func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) { + // Validate date range + if req.DateFrom.After(req.DateTo) { + return nil, fmt.Errorf("date_from cannot be after date_to") + } + + // Get analytics data from repository + analyticsData, err := p.analyticsRepo.GetProductAnalyticsPerCategory(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) + if err != nil { + return nil, fmt.Errorf("failed to get product analytics per category: %w", err) + } + + // Transform data + var resultData []models.ProductAnalyticsPerCategoryData + for _, data := range analyticsData { + resultData = append(resultData, models.ProductAnalyticsPerCategoryData{ + CategoryID: data.CategoryID, + CategoryName: data.CategoryName, + TotalRevenue: data.TotalRevenue, + TotalQuantity: data.TotalQuantity, + ProductCount: data.ProductCount, + OrderCount: data.OrderCount, + }) + } + + return &models.ProductAnalyticsPerCategoryResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: req.DateFrom, + DateTo: req.DateTo, + Data: resultData, + }, nil +} + func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) { // Validate date range if req.DateFrom.After(req.DateTo) { diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 35ef086..b69b39e 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -14,6 +14,7 @@ type AnalyticsRepository interface { GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) GetSalesAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.SalesAnalytics, error) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) + GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) } @@ -140,6 +141,38 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ return results, err } +func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) { + var results []*entities.ProductAnalyticsPerCategory + + query := r.db.WithContext(ctx). + Table("order_items oi"). + Select(` + c.id as category_id, + c.name as category_name, + COALESCE(SUM(oi.total_price), 0) as total_revenue, + COALESCE(SUM(oi.quantity), 0) as total_quantity, + COUNT(DISTINCT p.id) as product_count, + COUNT(DISTINCT oi.order_id) as order_count + `). + Joins("JOIN products p ON oi.product_id = p.id"). + Joins("JOIN categories c ON p.category_id = c.id"). + Joins("JOIN orders o ON oi.order_id = o.id"). + Where("o.organization_id = ?", organizationID). + Where("o.is_void = ?", false). + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) + + if outletID != nil { + query = query.Where("o.outlet_id = ?", *outletID) + } + + err := query. + Group("c.id, c.name"). + Order("total_revenue DESC"). + Scan(&results).Error + + return results, err +} + func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) { var result entities.DashboardOverview diff --git a/internal/router/router.go b/internal/router/router.go index 133b18a..b103a17 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -269,6 +269,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { analytics.GET("/payment-methods", r.analyticsHandler.GetPaymentMethodAnalytics) analytics.GET("/sales", r.analyticsHandler.GetSalesAnalytics) analytics.GET("/products", r.analyticsHandler.GetProductAnalytics) + analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory) analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics) analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) } diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index bf1f381..9be78cd 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -14,6 +14,7 @@ type AnalyticsService interface { GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) + GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) } @@ -71,6 +72,21 @@ func (s *AnalyticsServiceImpl) GetProductAnalytics(ctx context.Context, req *mod return response, nil } +func (s *AnalyticsServiceImpl) GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) { + // Validate request + if err := s.validateProductAnalyticsPerCategoryRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + // Process analytics request + response, err := s.analyticsProcessor.GetProductAnalyticsPerCategory(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get product analytics per category: %w", err) + } + + return response, nil +} + func (s *AnalyticsServiceImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) { // Validate request if err := s.validateDashboardAnalyticsRequest(req); err != nil { @@ -176,6 +192,26 @@ func (s *AnalyticsServiceImpl) validateProductAnalyticsRequest(req *models.Produ return nil } +func (s *AnalyticsServiceImpl) validateProductAnalyticsPerCategoryRequest(req *models.ProductAnalyticsPerCategoryRequest) error { + if req.OrganizationID == uuid.Nil { + return fmt.Errorf("organization ID is required") + } + + if req.DateFrom.IsZero() { + return fmt.Errorf("date_from is required") + } + + if req.DateTo.IsZero() { + return fmt.Errorf("date_to is required") + } + + if req.DateFrom.After(req.DateTo) { + return fmt.Errorf("date_from cannot be after date_to") + } + + return nil +} + func (s *AnalyticsServiceImpl) validateDashboardAnalyticsRequest(req *models.DashboardAnalyticsRequest) error { if req.OrganizationID == uuid.Nil { return fmt.Errorf("organization ID is required") diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 48bbfa1..96b7786 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -175,6 +175,55 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con } } +// ProductAnalyticsPerCategoryContractToModel converts contract request to model +func ProductAnalyticsPerCategoryContractToModel(req *contract.ProductAnalyticsPerCategoryRequest) *models.ProductAnalyticsPerCategoryRequest { + var dateFrom, dateTo time.Time + + // Parse date range using utility function + if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil { + if fromTime != nil { + dateFrom = *fromTime + } + if toTime != nil { + dateTo = *toTime + } + } + + return &models.ProductAnalyticsPerCategoryRequest{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: dateFrom, + DateTo: dateTo, + } +} + +// ProductAnalyticsPerCategoryModelToContract converts model response to contract +func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPerCategoryResponse) *contract.ProductAnalyticsPerCategoryResponse { + if resp == nil { + return nil + } + + var data []contract.ProductAnalyticsPerCategoryData + for _, item := range resp.Data { + data = append(data, contract.ProductAnalyticsPerCategoryData{ + CategoryID: item.CategoryID, + CategoryName: item.CategoryName, + TotalRevenue: item.TotalRevenue, + TotalQuantity: item.TotalQuantity, + ProductCount: item.ProductCount, + OrderCount: item.OrderCount, + }) + } + + return &contract.ProductAnalyticsPerCategoryResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, + Data: data, + } +} + // DashboardAnalyticsContractToModel converts contract request to model func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest { var dateFrom, dateTo time.Time diff --git a/server b/server new file mode 100755 index 0000000..cc565bf Binary files /dev/null and b/server differ