This commit is contained in:
Aditya Siregar 2025-08-14 00:45:14 +07:00
parent 7adba2c8f5
commit 3a0c262c77
10 changed files with 242 additions and 0 deletions

View File

@ -113,6 +113,33 @@ type ProductAnalyticsData struct {
OrderCount int64 `json:"order_count"` 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 // DashboardAnalyticsRequest represents the request for dashboard analytics
type DashboardAnalyticsRequest struct { type DashboardAnalyticsRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID

View File

@ -39,6 +39,16 @@ type ProductAnalytics struct {
OrderCount int64 `json:"order_count"` 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 // DashboardOverview represents dashboard overview data
type DashboardOverview struct { type DashboardOverview struct {
TotalSales float64 `json:"total_sales"` TotalSales float64 `json:"total_sales"`

View File

@ -97,6 +97,30 @@ func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalytics") 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) { func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx) contextInfo := appcontext.FromGinContext(ctx)

View File

@ -117,6 +117,33 @@ type ProductAnalyticsData struct {
OrderCount int64 `json:"order_count"` 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 // DashboardAnalyticsRequest represents the request for dashboard analytics
type DashboardAnalyticsRequest struct { type DashboardAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"` OrganizationID uuid.UUID `validate:"required"`

View File

@ -13,6 +13,7 @@ type AnalyticsProcessor interface {
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, 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) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, 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 }, 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) { func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
// Validate date range // Validate date range
if req.DateFrom.After(req.DateTo) { if req.DateFrom.After(req.DateTo) {

View File

@ -14,6 +14,7 @@ type AnalyticsRepository interface {
GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) 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) 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) 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) 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) 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 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) { func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) {
var result entities.DashboardOverview var result entities.DashboardOverview

View File

@ -269,6 +269,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
analytics.GET("/payment-methods", r.analyticsHandler.GetPaymentMethodAnalytics) analytics.GET("/payment-methods", r.analyticsHandler.GetPaymentMethodAnalytics)
analytics.GET("/sales", r.analyticsHandler.GetSalesAnalytics) analytics.GET("/sales", r.analyticsHandler.GetSalesAnalytics)
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics) analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics) analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
} }

View File

@ -14,6 +14,7 @@ type AnalyticsService interface {
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, 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) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, 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 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) { func (s *AnalyticsServiceImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
// Validate request // Validate request
if err := s.validateDashboardAnalyticsRequest(req); err != nil { if err := s.validateDashboardAnalyticsRequest(req); err != nil {
@ -176,6 +192,26 @@ func (s *AnalyticsServiceImpl) validateProductAnalyticsRequest(req *models.Produ
return nil 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 { func (s *AnalyticsServiceImpl) validateDashboardAnalyticsRequest(req *models.DashboardAnalyticsRequest) error {
if req.OrganizationID == uuid.Nil { if req.OrganizationID == uuid.Nil {
return fmt.Errorf("organization ID is required") return fmt.Errorf("organization ID is required")

View File

@ -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 // DashboardAnalyticsContractToModel converts contract request to model
func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest { func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest {
var dateFrom, dateTo time.Time var dateFrom, dateTo time.Time

BIN
server Executable file

Binary file not shown.