From b9ac97178fac2aed8f088e0b799f7dffb00f9b01 Mon Sep 17 00:00:00 2001 From: Efril Date: Wed, 5 Aug 2026 19:28:38 +0700 Subject: [PATCH] feat: profit sharing --- internal/constants/budget.go | 9 + internal/contract/analytics_contract.go | 129 +++++++++ internal/contract/category_contract.go | 5 + internal/entities/analytics.go | 33 +++ internal/entities/category.go | 2 + internal/handler/analytics_handler.go | 49 ++++ internal/handler/category_handler.go | 5 + internal/mappers/category_mapper.go | 14 + internal/models/analytics.go | 129 +++++++++ internal/models/category.go | 4 + internal/processor/analytics_processor.go | 239 +++++++++++++++++ .../processor/analytics_processor_test.go | 13 + internal/processor/budget_cutoff_test.go | 152 +++++++++++ internal/processor/category_processor.go | 30 +++ internal/repository/analytics_repository.go | 250 ++++++++++++++++++ internal/repository/category_repository.go | 10 +- internal/repository/product_repository.go | 15 ++ internal/router/router.go | 2 + internal/service/analytics_service.go | 76 ++++++ internal/service/analytics_service_test.go | 8 + internal/service/category_service.go | 3 + internal/transformer/analytics_transformer.go | 189 +++++++++++++ internal/transformer/category_transformer.go | 4 + internal/validator/category_validator.go | 2 +- ...00084_add_parent_id_to_categories.down.sql | 6 + .../000084_add_parent_id_to_categories.up.sql | 4 + 26 files changed, 1378 insertions(+), 4 deletions(-) create mode 100644 internal/constants/budget.go create mode 100644 internal/processor/budget_cutoff_test.go create mode 100644 migrations/000084_add_parent_id_to_categories.down.sql create mode 100644 migrations/000084_add_parent_id_to_categories.up.sql diff --git a/internal/constants/budget.go b/internal/constants/budget.go new file mode 100644 index 0000000..5bfa3d3 --- /dev/null +++ b/internal/constants/budget.go @@ -0,0 +1,9 @@ +package constants + +// Budget allocation of revenue used by the parent category cut-off report. +// The three shares are expected to add up to 100. +const ( + BudgetLimitPurchasePercent = 60.0 + BudgetLimitOwnerPercent = 20.0 + BudgetLimitTeamPercent = 20.0 +) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index f02570b..b49fd58 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -219,6 +219,135 @@ type ProductAnalyticsPerCategoryData struct { TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` } +// ProductAnalyticsPerParentCategoryRequest represents the request for product analytics per parent category +type ProductAnalyticsPerParentCategoryRequest struct { + OrganizationID uuid.UUID + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` +} + +// ProductAnalyticsPerParentCategoryResponse represents the response for product analytics per parent category +type ProductAnalyticsPerParentCategoryResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + OutletName *string `json:"outlet_name,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + Data []ProductAnalyticsPerParentCategoryData `json:"data"` + Budget BudgetCutOff `json:"budget"` +} + +type ProductAnalyticsPerParentCategoryData struct { + ParentCategoryID uuid.UUID `json:"parent_category_id"` + ParentCategoryName string `json:"parent_category_name"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + CategoryCount int64 `json:"category_count"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` +} + +// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category +type ParentCategoryAnalyticsDetailRequest struct { + OrganizationID uuid.UUID + ParentCategoryID string + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` +} + +// ParentCategoryAnalyticsDetailResponse represents the drill-down of one parent category +type ParentCategoryAnalyticsDetailResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + OutletName *string `json:"outlet_name,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + ParentCategoryID uuid.UUID `json:"parent_category_id"` + ParentCategoryName string `json:"parent_category_name"` + Summary ParentCategoryAnalyticsDetailSummary `json:"summary"` + Categories []ParentCategoryAnalyticsDetailData `json:"categories"` + Budget BudgetCutOff `json:"budget"` +} + +type ParentCategoryAnalyticsDetailSummary struct { + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + CategoryCount int64 `json:"category_count"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` +} + +type ParentCategoryAnalyticsDetailData 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"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + Products []ParentCategoryAnalyticsProductData `json:"products"` +} + +type ParentCategoryAnalyticsProductData struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + ProductSku string `json:"product_sku"` + ProductPrice float64 `json:"product_price"` + QuantitySold int64 `json:"quantity_sold"` + Revenue float64 `json:"revenue"` + AveragePrice float64 `json:"average_price"` + OrderCount int64 `json:"order_count"` + StandardHppPerUnit float64 `json:"standard_hpp_per_unit"` + StandardHppTotal float64 `json:"standard_hpp_total"` + FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"` + FifoHppTotal float64 `json:"fifo_hpp_total"` + MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"` + MovingAverageHppTotal float64 `json:"moving_average_hpp_total"` +} + +// BudgetCutOff is the Monday-to-Sunday spending limit breakdown attached to the +// parent category reports. +type BudgetCutOff struct { + Percentages BudgetPercentages `json:"percentages"` + CutOffFrom time.Time `json:"cut_off_from"` + CutOffTo time.Time `json:"cut_off_to"` + Total BudgetPeriod `json:"total"` + Weekly []BudgetPeriod `json:"weekly"` + Monthly []BudgetMonthPeriod `json:"monthly"` +} + +type BudgetPercentages struct { + Purchase float64 `json:"purchase"` + Owner float64 `json:"owner"` + Team float64 `json:"team"` +} + +type BudgetPeriod struct { + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + Revenue float64 `json:"revenue"` + OrderCount int64 `json:"order_count"` + LimitPurchase float64 `json:"limit_purchase"` + LimitOwner float64 `json:"limit_owner"` + LimitTeam float64 `json:"limit_team"` +} + +type BudgetMonthPeriod struct { + Month string `json:"month"` + WeekCount int `json:"week_count"` + BudgetPeriod +} + // DashboardAnalyticsRequest represents the request for dashboard analytics type DashboardAnalyticsRequest struct { OrganizationID uuid.UUID diff --git a/internal/contract/category_contract.go b/internal/contract/category_contract.go index 7c74e82..2b10a14 100644 --- a/internal/contract/category_contract.go +++ b/internal/contract/category_contract.go @@ -11,6 +11,7 @@ type CreateCategoryRequest struct { Description *string `json:"description,omitempty"` BusinessType *string `json:"business_type,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` Order *int `json:"order,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -20,6 +21,7 @@ type UpdateCategoryRequest struct { Description *string `json:"description,omitempty"` BusinessType *string `json:"business_type,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` Order *int `json:"order,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -27,6 +29,7 @@ type UpdateCategoryRequest struct { type ListCategoriesRequest struct { OrganizationID *uuid.UUID `json:"organization_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` BusinessType string `json:"business_type,omitempty"` Search string `json:"search,omitempty"` Page int `json:"page" validate:"required,min=1"` @@ -38,6 +41,8 @@ type CategoryResponse struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` + ParentName *string `json:"parent_name,omitempty"` Name string `json:"name"` Description *string `json:"description"` BusinessType string `json:"business_type"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index f911024..4ea0933 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -112,6 +112,39 @@ type ProductAnalyticsPerCategory struct { TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` } +// ProductAnalyticsPerParentCategory rolls the per-category figures up to the +// top-level category. A category without a parent is its own group. +type ProductAnalyticsPerParentCategory struct { + ParentCategoryID uuid.UUID `json:"parent_category_id"` + ParentCategoryName string `json:"parent_category_name"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + CategoryCount int64 `json:"category_count"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` +} + +// ParentCategoryAnalyticsDetail is the drill-down for a single parent category: +// its own totals, the sub-categories underneath it, and the products in each. +type ParentCategoryAnalyticsDetail struct { + ParentCategoryID uuid.UUID + ParentCategoryName string + Summary *ProductAnalyticsPerParentCategory + Categories []*ProductAnalyticsPerCategory + Products []*ProductAnalytics +} + +// BudgetCutOffWeek is one Monday-to-Sunday bucket of revenue, used to derive the +// weekly spending limits. +type BudgetCutOffWeek struct { + WeekStart time.Time `json:"week_start"` + Revenue float64 `json:"revenue"` + OrderCount int64 `json:"order_count"` +} + // DashboardOverview represents dashboard overview data type DashboardOverview struct { TotalSales float64 `json:"total_sales"` diff --git a/internal/entities/category.go b/internal/entities/category.go index 8cb00e1..3d0aafc 100644 --- a/internal/entities/category.go +++ b/internal/entities/category.go @@ -34,6 +34,8 @@ type Category struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"` + ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"` + Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"` Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` Description *string `gorm:"type:text" json:"description"` Order int `gorm:"default:0" json:"order"` diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go index 438f932..32b6c4d 100644 --- a/internal/handler/analytics_handler.go +++ b/internal/handler/analytics_handler.go @@ -157,6 +157,55 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerCategory") } +func (h *AnalyticsHandler) GetProductAnalyticsPerParentCategory(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ProductAnalyticsPerParentCategoryRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProductAnalyticsPerParentCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerParentCategory") + return + } + + req.OrganizationID = contextInfo.OrganizationID + req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) + modelReq := transformer.ProductAnalyticsPerParentCategoryContractToModel(&req) + + response, err := h.analyticsService.GetProductAnalyticsPerParentCategory(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetProductAnalyticsPerParentCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerParentCategory") + return + } + + contractResp := transformer.ProductAnalyticsPerParentCategoryModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerParentCategory") +} + +func (h *AnalyticsHandler) GetParentCategoryAnalyticsDetail(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ParentCategoryAnalyticsDetailRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetParentCategoryAnalyticsDetail", err.Error())}), "AnalyticsHandler::GetParentCategoryAnalyticsDetail") + return + } + + req.OrganizationID = contextInfo.OrganizationID + req.ParentCategoryID = c.Param("parent_category_id") + req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) + modelReq := transformer.ParentCategoryAnalyticsDetailContractToModel(&req) + + response, err := h.analyticsService.GetParentCategoryAnalyticsDetail(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetParentCategoryAnalyticsDetail", err.Error())}), "AnalyticsHandler::GetParentCategoryAnalyticsDetail") + return + } + + contractResp := transformer.ParentCategoryAnalyticsDetailModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetParentCategoryAnalyticsDetail") +} + func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) { ctx := c.Request.Context() contextInfo := appcontext.FromGinContext(ctx) diff --git a/internal/handler/category_handler.go b/internal/handler/category_handler.go index 21b21b2..a5b4e22 100644 --- a/internal/handler/category_handler.go +++ b/internal/handler/category_handler.go @@ -191,6 +191,11 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) { req.OutletID = &outletID } } + if parentIDStr := c.Query("parent_id"); parentIDStr != "" { + if parentID, err := uuid.Parse(parentIDStr); err == nil { + req.ParentID = &parentID + } + } validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req) if validationError != nil { logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed") diff --git a/internal/mappers/category_mapper.go b/internal/mappers/category_mapper.go index 71fa0aa..bbfe984 100644 --- a/internal/mappers/category_mapper.go +++ b/internal/mappers/category_mapper.go @@ -61,6 +61,7 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities. return &entities.Category{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, + ParentID: req.ParentID, Name: req.Name, Description: req.Description, Order: req.Order, @@ -85,10 +86,19 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons } } + // Parent name is only available when the Parent association is preloaded + var parentName *string + if entity.Parent != nil { + name := entity.Parent.Name + parentName = &name + } + return &models.CategoryResponse{ ID: entity.ID, OrganizationID: entity.OrganizationID, OutletID: entity.OutletID, + ParentID: entity.ParentID, + ParentName: parentName, Name: entity.Name, Description: entity.Description, ImageURL: imageURL, @@ -127,6 +137,10 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda if req.OutletID != nil { entity.OutletID = req.OutletID } + + if req.ParentID != nil { + entity.ParentID = req.ParentID + } } func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category { diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 9e32fd7..60b511d 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -229,6 +229,135 @@ type ProductAnalyticsPerCategoryData struct { TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` } +// ProductAnalyticsPerParentCategoryRequest represents the request for product analytics per parent category +type ProductAnalyticsPerParentCategoryRequest struct { + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` +} + +// ProductAnalyticsPerParentCategoryResponse represents the response for product analytics per parent category +type ProductAnalyticsPerParentCategoryResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + OutletName *string `json:"outlet_name,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + Data []ProductAnalyticsPerParentCategoryData `json:"data"` + Budget BudgetCutOff `json:"budget"` +} + +type ProductAnalyticsPerParentCategoryData struct { + ParentCategoryID uuid.UUID `json:"parent_category_id"` + ParentCategoryName string `json:"parent_category_name"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + CategoryCount int64 `json:"category_count"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` +} + +// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category +type ParentCategoryAnalyticsDetailRequest struct { + OrganizationID uuid.UUID `validate:"required"` + ParentCategoryID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` +} + +// ParentCategoryAnalyticsDetailResponse represents the drill-down of one parent category +type ParentCategoryAnalyticsDetailResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + OutletName *string `json:"outlet_name,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + ParentCategoryID uuid.UUID `json:"parent_category_id"` + ParentCategoryName string `json:"parent_category_name"` + Summary ParentCategoryAnalyticsDetailSummary `json:"summary"` + Categories []ParentCategoryAnalyticsDetailData `json:"categories"` + Budget BudgetCutOff `json:"budget"` +} + +type ParentCategoryAnalyticsDetailSummary struct { + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + CategoryCount int64 `json:"category_count"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` +} + +type ParentCategoryAnalyticsDetailData 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"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + Products []ParentCategoryAnalyticsProductData `json:"products"` +} + +type ParentCategoryAnalyticsProductData struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + ProductSku string `json:"product_sku"` + ProductPrice float64 `json:"product_price"` + QuantitySold int64 `json:"quantity_sold"` + Revenue float64 `json:"revenue"` + AveragePrice float64 `json:"average_price"` + OrderCount int64 `json:"order_count"` + StandardHppPerUnit float64 `json:"standard_hpp_per_unit"` + StandardHppTotal float64 `json:"standard_hpp_total"` + FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"` + FifoHppTotal float64 `json:"fifo_hpp_total"` + MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"` + MovingAverageHppTotal float64 `json:"moving_average_hpp_total"` +} + +// BudgetCutOff is the Monday-to-Sunday spending limit breakdown attached to the +// parent category reports. +type BudgetCutOff struct { + Percentages BudgetPercentages `json:"percentages"` + CutOffFrom time.Time `json:"cut_off_from"` + CutOffTo time.Time `json:"cut_off_to"` + Total BudgetPeriod `json:"total"` + Weekly []BudgetPeriod `json:"weekly"` + Monthly []BudgetMonthPeriod `json:"monthly"` +} + +type BudgetPercentages struct { + Purchase float64 `json:"purchase"` + Owner float64 `json:"owner"` + Team float64 `json:"team"` +} + +type BudgetPeriod struct { + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + Revenue float64 `json:"revenue"` + OrderCount int64 `json:"order_count"` + LimitPurchase float64 `json:"limit_purchase"` + LimitOwner float64 `json:"limit_owner"` + LimitTeam float64 `json:"limit_team"` +} + +type BudgetMonthPeriod struct { + Month string `json:"month"` + WeekCount int `json:"week_count"` + BudgetPeriod +} + // DashboardAnalyticsRequest represents the request for dashboard analytics type DashboardAnalyticsRequest struct { OrganizationID uuid.UUID `validate:"required"` diff --git a/internal/models/category.go b/internal/models/category.go index 443083b..0d20cd4 100644 --- a/internal/models/category.go +++ b/internal/models/category.go @@ -22,6 +22,7 @@ type Category struct { type CreateCategoryRequest struct { OrganizationID uuid.UUID `validate:"required"` OutletID *uuid.UUID + ParentID *uuid.UUID Name string `validate:"required,min=1,max=255"` Description *string `validate:"omitempty,max=1000"` ImageURL *string `validate:"omitempty,url"` @@ -33,6 +34,7 @@ type UpdateCategoryRequest struct { Description *string `validate:"omitempty,max=1000"` ImageURL *string `validate:"omitempty,url"` OutletID *uuid.UUID + ParentID *uuid.UUID Order *int `validate:"omitempty,min=0"` IsActive *bool } @@ -41,6 +43,8 @@ type CategoryResponse struct { ID uuid.UUID OrganizationID uuid.UUID OutletID *uuid.UUID + ParentID *uuid.UUID + ParentName *string Name string Description *string ImageURL *string diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 765ba5a..2560124 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" @@ -19,6 +20,8 @@ type AnalyticsProcessor interface { GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) + GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) + GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) @@ -356,6 +359,242 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont }, nil } +func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, 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.GetProductAnalyticsPerParentCategory(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) + if err != nil { + return nil, fmt.Errorf("failed to get product analytics per parent category: %w", err) + } + + // Transform data + var resultData []models.ProductAnalyticsPerParentCategoryData + for _, data := range analyticsData { + resultData = append(resultData, models.ProductAnalyticsPerParentCategoryData{ + ParentCategoryID: data.ParentCategoryID, + ParentCategoryName: data.ParentCategoryName, + TotalRevenue: data.TotalRevenue, + TotalQuantity: data.TotalQuantity, + CategoryCount: data.CategoryCount, + ProductCount: data.ProductCount, + OrderCount: data.OrderCount, + TotalStandardHpp: data.TotalStandardHpp, + TotalFifoHpp: data.TotalFifoHpp, + TotalMovingAverageHpp: data.TotalMovingAverageHpp, + }) + } + + budget, err := p.buildBudgetCutOff(ctx, req.OrganizationID, req.OutletID, nil, req.DateFrom, req.DateTo) + if err != nil { + return nil, err + } + + return &models.ProductAnalyticsPerParentCategoryResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID), + DateFrom: req.DateFrom, + DateTo: req.DateTo, + Data: resultData, + Budget: budget, + }, nil +} + +func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) { + // Validate date range + if req.DateFrom.After(req.DateTo) { + return nil, fmt.Errorf("date_from cannot be after date_to") + } + + detail, err := p.analyticsRepo.GetParentCategoryAnalyticsDetail(ctx, req.OrganizationID, req.OutletID, req.ParentCategoryID, req.DateFrom, req.DateTo) + if err != nil { + return nil, fmt.Errorf("failed to get parent category analytics detail: %w", err) + } + + // Bucket the product rows by the category they belong to + productsByCategory := make(map[uuid.UUID][]models.ParentCategoryAnalyticsProductData) + for _, product := range detail.Products { + productsByCategory[product.CategoryID] = append(productsByCategory[product.CategoryID], models.ParentCategoryAnalyticsProductData{ + ProductID: product.ProductID, + ProductName: product.ProductName, + ProductSku: product.ProductSku, + ProductPrice: product.ProductPrice, + QuantitySold: product.QuantitySold, + Revenue: product.Revenue, + AveragePrice: product.AveragePrice, + OrderCount: product.OrderCount, + StandardHppPerUnit: product.StandardHppPerUnit, + StandardHppTotal: product.StandardHppTotal, + FifoHppPerUnit: product.FifoHppPerUnit, + FifoHppTotal: product.FifoHppTotal, + MovingAverageHppPerUnit: product.MovingAverageHppPerUnit, + MovingAverageHppTotal: product.MovingAverageHppTotal, + }) + } + + categories := make([]models.ParentCategoryAnalyticsDetailData, 0, len(detail.Categories)) + for _, category := range detail.Categories { + products := productsByCategory[category.CategoryID] + if products == nil { + products = []models.ParentCategoryAnalyticsProductData{} + } + + categories = append(categories, models.ParentCategoryAnalyticsDetailData{ + CategoryID: category.CategoryID, + CategoryName: category.CategoryName, + TotalRevenue: category.TotalRevenue, + TotalQuantity: category.TotalQuantity, + ProductCount: category.ProductCount, + OrderCount: category.OrderCount, + TotalStandardHpp: category.TotalStandardHpp, + TotalFifoHpp: category.TotalFifoHpp, + TotalMovingAverageHpp: category.TotalMovingAverageHpp, + Products: products, + }) + } + + summary := models.ParentCategoryAnalyticsDetailSummary{} + if detail.Summary != nil { + summary = models.ParentCategoryAnalyticsDetailSummary{ + TotalRevenue: detail.Summary.TotalRevenue, + TotalQuantity: detail.Summary.TotalQuantity, + CategoryCount: detail.Summary.CategoryCount, + ProductCount: detail.Summary.ProductCount, + OrderCount: detail.Summary.OrderCount, + TotalStandardHpp: detail.Summary.TotalStandardHpp, + TotalFifoHpp: detail.Summary.TotalFifoHpp, + TotalMovingAverageHpp: detail.Summary.TotalMovingAverageHpp, + } + } + + budget, err := p.buildBudgetCutOff(ctx, req.OrganizationID, req.OutletID, &req.ParentCategoryID, req.DateFrom, req.DateTo) + if err != nil { + return nil, err + } + + return &models.ParentCategoryAnalyticsDetailResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID), + DateFrom: req.DateFrom, + DateTo: req.DateTo, + ParentCategoryID: detail.ParentCategoryID, + ParentCategoryName: detail.ParentCategoryName, + Summary: summary, + Categories: categories, + Budget: budget, + }, nil +} + +// startOfWeek returns the Monday 00:00 of the week containing t, in t's own location. +func startOfWeek(t time.Time) time.Time { + daysSinceMonday := (int(t.Weekday()) + 6) % 7 + year, month, day := t.Date() + return time.Date(year, month, day-daysSinceMonday, 0, 0, 0, 0, t.Location()) +} + +// endOfWeek returns the Sunday 23:59:59.999999999 of the week containing t. +func endOfWeek(t time.Time) time.Time { + return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond) +} + +// newBudgetPeriod splits a period's revenue into the spending limits. +func newBudgetPeriod(start, end time.Time, revenue float64, orderCount int64) models.BudgetPeriod { + return models.BudgetPeriod{ + PeriodStart: start, + PeriodEnd: end, + Revenue: revenue, + OrderCount: orderCount, + LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100, + LimitOwner: revenue * constants.BudgetLimitOwnerPercent / 100, + LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100, + } +} + +// buildBudgetCutOff produces the weekly cut-off breakdown for the given scope. Weeks +// are always whole Monday-to-Sunday blocks, so the covered range is widened to the +// week boundaries around the requested dates. A nil parentCategoryID covers every +// category. +func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, dateFrom, dateTo time.Time) (models.BudgetCutOff, error) { + cutOffFrom := startOfWeek(dateFrom) + cutOffTo := endOfWeek(dateTo) + + budget := models.BudgetCutOff{ + Percentages: models.BudgetPercentages{ + Purchase: constants.BudgetLimitPurchasePercent, + Owner: constants.BudgetLimitOwnerPercent, + Team: constants.BudgetLimitTeamPercent, + }, + CutOffFrom: cutOffFrom, + CutOffTo: cutOffTo, + Weekly: []models.BudgetPeriod{}, + Monthly: []models.BudgetMonthPeriod{}, + } + + rows, err := p.analyticsRepo.GetBudgetCutOffWeekly(ctx, organizationID, outletID, parentCategoryID, cutOffFrom, cutOffTo) + if err != nil { + return budget, fmt.Errorf("failed to get budget cut off: %w", err) + } + + // Key the rows by their Monday so weeks without any sales can still be emitted + rowsByWeek := make(map[string]*entities.BudgetCutOffWeek, len(rows)) + for _, row := range rows { + rowsByWeek[row.WeekStart.In(cutOffFrom.Location()).Format("2006-01-02")] = row + } + + var ( + totalRevenue float64 + totalOrders int64 + monthOrder []string + monthAccumulator = map[string]*models.BudgetMonthPeriod{} + ) + + for week := cutOffFrom; !week.After(cutOffTo); week = week.AddDate(0, 0, 7) { + var revenue float64 + var orderCount int64 + if row, ok := rowsByWeek[week.Format("2006-01-02")]; ok { + revenue, orderCount = row.Revenue, row.OrderCount + } + + period := newBudgetPeriod(week, endOfWeek(week), revenue, orderCount) + budget.Weekly = append(budget.Weekly, period) + + totalRevenue += revenue + totalOrders += orderCount + + // A week belongs to the month of its Monday, so every week is counted once + monthKey := week.Format("2006-01") + month, ok := monthAccumulator[monthKey] + if !ok { + month = &models.BudgetMonthPeriod{Month: monthKey} + month.PeriodStart = period.PeriodStart + monthAccumulator[monthKey] = month + monthOrder = append(monthOrder, monthKey) + } + month.WeekCount++ + month.PeriodEnd = period.PeriodEnd + month.Revenue += revenue + month.OrderCount += orderCount + } + + for _, monthKey := range monthOrder { + month := monthAccumulator[monthKey] + budget.Monthly = append(budget.Monthly, models.BudgetMonthPeriod{ + Month: month.Month, + WeekCount: month.WeekCount, + BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.OrderCount), + }) + } + + budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalOrders) + + return budget, 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/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 23d4967..6cb4722 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -14,6 +14,7 @@ import ( type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics + budgetCutOffWeeks []*entities.BudgetCutOffWeek profitLossResult *entities.ProfitLossAnalytics exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics bankBalances []entities.ExclusiveSummaryBankBalance @@ -43,6 +44,18 @@ func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, u return nil, nil } +func (analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) { + return nil, nil +} + +func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) { + return nil, nil +} + +func (s analyticsRepositoryStub) GetBudgetCutOffWeekly(context.Context, uuid.UUID, *uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.BudgetCutOffWeek, error) { + return s.budgetCutOffWeeks, nil +} + func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) { return nil, nil } diff --git a/internal/processor/budget_cutoff_test.go b/internal/processor/budget_cutoff_test.go new file mode 100644 index 0000000..7386b4c --- /dev/null +++ b/internal/processor/budget_cutoff_test.go @@ -0,0 +1,152 @@ +package processor + +import ( + "context" + "testing" + "time" + + "apskel-pos-be/internal/entities" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func jakarta(t *testing.T) *time.Location { + t.Helper() + loc, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + return loc +} + +func TestStartOfWeekLandsOnMonday(t *testing.T) { + loc := jakarta(t) + + // 3 Aug 2026 is a Monday, so the whole week must collapse onto it + monday := time.Date(2026, 8, 3, 0, 0, 0, 0, loc) + + for offset := 0; offset < 7; offset++ { + day := monday.AddDate(0, 0, offset).Add(13 * time.Hour) + + got := startOfWeek(day) + require.Equal(t, monday, got, "day %s should map to %s", day, monday) + require.Equal(t, time.Monday, got.Weekday()) + } +} + +func TestEndOfWeekLandsOnSunday(t *testing.T) { + loc := jakarta(t) + + // Sunday 9 Aug 2026 closes the week that starts Monday 3 Aug + got := endOfWeek(time.Date(2026, 8, 5, 9, 30, 0, 0, loc)) + + require.Equal(t, time.Sunday, got.Weekday()) + require.Equal(t, 2026, got.Year()) + require.Equal(t, time.August, got.Month()) + require.Equal(t, 9, got.Day()) + require.Equal(t, 23, got.Hour()) + require.Equal(t, 59, got.Minute()) +} + +func TestBuildBudgetCutOffWidensToWholeWeeks(t *testing.T) { + loc := jakarta(t) + processor := &AnalyticsProcessorImpl{analyticsRepo: &analyticsRepositoryStub{}} + + // Saturday 1 Aug to Monday 31 Aug 2026: both ends fall mid-week + from := time.Date(2026, 8, 1, 0, 0, 0, 0, loc) + to := time.Date(2026, 8, 31, 23, 59, 59, 0, loc) + + budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, from, to) + require.NoError(t, err) + + // Reaches back into July and forward into September to keep weeks whole + require.Equal(t, time.Monday, budget.CutOffFrom.Weekday()) + require.Equal(t, time.July, budget.CutOffFrom.Month()) + require.Equal(t, 27, budget.CutOffFrom.Day()) + require.Equal(t, time.Sunday, budget.CutOffTo.Weekday()) + require.Equal(t, time.September, budget.CutOffTo.Month()) + require.Equal(t, 6, budget.CutOffTo.Day()) + + require.Len(t, budget.Weekly, 6) + for _, week := range budget.Weekly { + require.Equal(t, time.Monday, week.PeriodStart.Weekday()) + require.Equal(t, time.Sunday, week.PeriodEnd.Weekday()) + } + + // A week is filed under the month of its Monday, so the 27 Jul week counts as July + require.Len(t, budget.Monthly, 2) + require.Equal(t, "2026-07", budget.Monthly[0].Month) + require.Equal(t, 1, budget.Monthly[0].WeekCount) + require.Equal(t, "2026-08", budget.Monthly[1].Month) + require.Equal(t, 5, budget.Monthly[1].WeekCount) +} + +func TestBuildBudgetCutOffAppliesLimits(t *testing.T) { + loc := jakarta(t) + weekStart := time.Date(2026, 8, 3, 0, 0, 0, 0, loc) + + stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{ + {WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120}, + }} + processor := &AnalyticsProcessorImpl{analyticsRepo: stub} + + budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, weekStart, weekStart.AddDate(0, 0, 6)) + require.NoError(t, err) + require.Len(t, budget.Weekly, 1) + + // 60 / 20 / 20 of the week's revenue + week := budget.Weekly[0] + require.Equal(t, float64(6_000_000), week.LimitPurchase) + require.Equal(t, float64(2_000_000), week.LimitOwner) + require.Equal(t, float64(2_000_000), week.LimitTeam) + require.Equal(t, int64(120), week.OrderCount) + + // Totals mirror the single week + require.Equal(t, week.Revenue, budget.Total.Revenue) + require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase) +} + +func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) { + loc := jakarta(t) + first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc) + + stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{ + {WeekStart: first, Revenue: 10_000_000, OrderCount: 100}, + {WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60}, + }} + processor := &AnalyticsProcessorImpl{analyticsRepo: stub} + + budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, first, first.AddDate(0, 0, 9)) + require.NoError(t, err) + + require.Len(t, budget.Monthly, 1) + month := budget.Monthly[0] + require.Equal(t, "2026-08", month.Month) + require.Equal(t, 2, month.WeekCount) + require.Equal(t, float64(15_000_000), month.Revenue) + require.Equal(t, int64(160), month.OrderCount) + + // The month limit is the accumulation of its weeks + require.Equal(t, float64(9_000_000), month.LimitPurchase) + require.Equal(t, budget.Weekly[0].LimitPurchase+budget.Weekly[1].LimitPurchase, month.LimitPurchase) +} + +func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) { + loc := jakarta(t) + first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc) + + // Only the third week has sales; the two quiet weeks must still be reported + stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{ + {WeekStart: first.AddDate(0, 0, 14), Revenue: 4_000_000, OrderCount: 40}, + }} + processor := &AnalyticsProcessorImpl{analyticsRepo: stub} + + budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, first, first.AddDate(0, 0, 16)) + require.NoError(t, err) + + require.Len(t, budget.Weekly, 3) + require.Zero(t, budget.Weekly[0].Revenue) + require.Zero(t, budget.Weekly[0].LimitPurchase) + require.Zero(t, budget.Weekly[1].Revenue) + require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue) + require.Equal(t, float64(4_000_000), budget.Total.Revenue) +} diff --git a/internal/processor/category_processor.go b/internal/processor/category_processor.go index 76f68e6..73be6d5 100644 --- a/internal/processor/category_processor.go +++ b/internal/processor/category_processor.go @@ -53,6 +53,18 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models. return nil, fmt.Errorf("category with name '%s' already exists for this organization", req.Name) } + var parentName *string + if req.ParentID != nil { + parentCategory, err := p.categoryRepo.GetByID(ctx, *req.ParentID) + if err != nil { + return nil, fmt.Errorf("parent category not found: %w", err) + } + if parentCategory.OrganizationID != req.OrganizationID { + return nil, fmt.Errorf("parent category must belong to the same organization") + } + parentName = &parentCategory.Name + } + // Map request to entity categoryEntity := mappers.CreateCategoryRequestToEntity(req) @@ -63,6 +75,7 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models. // Map entity to response model response := mappers.CategoryEntityToResponse(categoryEntity) + response.ParentName = parentName return response, nil } @@ -84,6 +97,23 @@ func (p *CategoryProcessorImpl) UpdateCategory(ctx context.Context, id uuid.UUID } } + if req.ParentID != nil { + if *req.ParentID == id { + return nil, fmt.Errorf("category cannot be its own parent") + } + + parentCategory, err := p.categoryRepo.GetByID(ctx, *req.ParentID) + if err != nil { + return nil, fmt.Errorf("parent category not found: %w", err) + } + if parentCategory.OrganizationID != existingCategory.OrganizationID { + return nil, fmt.Errorf("parent category must belong to the same organization") + } + + // Refresh the preloaded association so the response carries the new parent + existingCategory.Parent = parentCategory + } + // Apply updates to entity mappers.UpdateCategoryEntityFromRequest(existingCategory, req) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index cfa15a0..a1d6a86 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "sort" "time" @@ -17,6 +18,9 @@ type AnalyticsRepository interface { GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, 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) + GetProductAnalyticsPerParentCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) + GetParentCategoryAnalyticsDetail(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID uuid.UUID, dateFrom, dateTo time.Time) (*entities.ParentCategoryAnalyticsDetail, error) + GetBudgetCutOffWeekly(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, cutOffFrom, cutOffTo time.Time) ([]*entities.BudgetCutOffWeek, 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) GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) @@ -461,6 +465,252 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con return results, err } +func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) { + var results []*entities.ProductAnalyticsPerParentCategory + + query := r.db.WithContext(ctx). + Table("order_items oi"). + Select(` + pc.id as parent_category_id, + pc.name as parent_category_name, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, + COUNT(DISTINCT c.id) as category_count, + COUNT(DISTINCT p.id) as product_count, + COUNT(DISTINCT oi.order_id) as order_count, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + `). + Joins("JOIN products p ON oi.product_id = p.id"). + Joins("JOIN categories c ON p.category_id = c.id"). + // Categories without a parent roll up to themselves, so top-level categories still appear + Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)"). + Joins("JOIN orders o ON oi.order_id = o.id"). + Joins("LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id"). + Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id", + r.db.Table("product_recipes pr2"). + Select("pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit"). + Joins("JOIN ingredients ing ON pr2.ingredient_id = ing.id"). + Joins("LEFT JOIN (?) ma ON ma.ingredient_id = pr2.ingredient_id", + r.db.Table("inventory_movements im"). + Select("im.item_id as ingredient_id, CASE WHEN SUM(im.quantity) > 0 THEN SUM(im.total_cost) / SUM(im.quantity) ELSE 0 END as moving_avg_cost"). + Where("im.movement_type = ?", "purchase"). + Where("im.item_type = ?", "INGREDIENT"). + Where("im.organization_id = ?", organizationID). + Where("im.created_at <= ?", dateTo). + Group("im.item_id"), + ). + Group("pr2.product_id"), + ). + Where("o.organization_id = ?", organizationID). + Where("o.is_void = ?", false). + Where("o.is_refund = ?", false). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("oi.status != ?", entities.OrderItemStatusCancelled). + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) + + query = r.resolveOutletID(query, outletID, "o.outlet_id") + + err := query. + Group("pc.id, pc.name"). + Order("pc.name ASC"). + Scan(&results).Error + + return results, err +} + +// movingAverageHppSubquery builds the per-product moving-average HPP lookup shared by +// the parent category detail queries. +func (r *AnalyticsRepositoryImpl) movingAverageHppSubquery(organizationID uuid.UUID, dateTo time.Time) *gorm.DB { + return r.db.Table("product_recipes pr2"). + Select("pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit"). + Joins("JOIN ingredients ing ON pr2.ingredient_id = ing.id"). + Joins("LEFT JOIN (?) ma ON ma.ingredient_id = pr2.ingredient_id", + r.db.Table("inventory_movements im"). + Select("im.item_id as ingredient_id, CASE WHEN SUM(im.quantity) > 0 THEN SUM(im.total_cost) / SUM(im.quantity) ELSE 0 END as moving_avg_cost"). + Where("im.movement_type = ?", "purchase"). + Where("im.item_type = ?", "INGREDIENT"). + Where("im.organization_id = ?", organizationID). + Where("im.created_at <= ?", dateTo). + Group("im.item_id"), + ). + Group("pr2.product_id") +} + +// parentCategoryScopedQuery builds the common order_items -> product -> category join +// restricted to a single parent category group. Categories without a parent belong to +// their own group, so a leaf category resolves to itself. +func (r *AnalyticsRepositoryImpl) parentCategoryScopedQuery(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID uuid.UUID, dateFrom, dateTo time.Time) *gorm.DB { + query := r.db.WithContext(ctx). + Table("order_items oi"). + 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"). + Joins("LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id"). + Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id", r.movingAverageHppSubquery(organizationID, dateTo)). + Where("COALESCE(c.parent_id, c.id) = ?", parentCategoryID). + Where("o.organization_id = ?", organizationID). + Where("o.is_void = ?", false). + Where("o.is_refund = ?", false). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("oi.status != ?", entities.OrderItemStatusCancelled). + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) + + return r.resolveOutletID(query, outletID, "o.outlet_id") +} + +func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID uuid.UUID, dateFrom, dateTo time.Time) (*entities.ParentCategoryAnalyticsDetail, error) { + // Resolve the category first so the endpoint still identifies the category when it + // has no sales in the requested range, and rejects ids from another organization. + var parent struct { + ID uuid.UUID + Name string + } + if err := r.db.WithContext(ctx). + Table("categories"). + Select("id, name"). + Where("id = ? AND organization_id = ?", parentCategoryID, organizationID). + Scan(&parent).Error; err != nil { + return nil, err + } + if parent.ID == uuid.Nil { + return nil, fmt.Errorf("category not found") + } + + detail := &entities.ParentCategoryAnalyticsDetail{ + ParentCategoryID: parent.ID, + ParentCategoryName: parent.Name, + Categories: []*entities.ProductAnalyticsPerCategory{}, + Products: []*entities.ProductAnalytics{}, + } + + // Totals for the whole parent group. Kept as its own aggregate because order_count + // is a COUNT(DISTINCT order) and cannot be recovered by summing the category rows. + summary := &entities.ProductAnalyticsPerParentCategory{} + err := r.parentCategoryScopedQuery(ctx, organizationID, outletID, parentCategoryID, dateFrom, dateTo). + Select(` + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, + COUNT(DISTINCT c.id) as category_count, + COUNT(DISTINCT p.id) as product_count, + COUNT(DISTINCT oi.order_id) as order_count, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + `). + Scan(summary).Error + if err != nil { + return nil, err + } + summary.ParentCategoryID = parent.ID + summary.ParentCategoryName = parent.Name + detail.Summary = summary + + // Sub-category rows. + err = r.parentCategoryScopedQuery(ctx, organizationID, outletID, parentCategoryID, dateFrom, dateTo). + Select(` + c.id as category_id, + c.name as category_name, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, + COUNT(DISTINCT p.id) as product_count, + COUNT(DISTINCT oi.order_id) as order_count, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + `). + Group("c.id, c.name, c.order"). + Order("c.order ASC, c.name ASC"). + Scan(&detail.Categories).Error + if err != nil { + return nil, err + } + + // Product rows. Uses the same refund-aware arithmetic as the rows above so the + // products of a category add up to that category's totals. + err = r.parentCategoryScopedQuery(ctx, organizationID, outletID, parentCategoryID, dateFrom, dateTo). + Joins("LEFT JOIN product_outlet_prices pop ON pop.product_id = p.id AND pop.outlet_id = o.outlet_id"). + Select(` + p.id as product_id, + p.name as product_name, + p.sku as product_sku, + COALESCE( + NULLIF(pop.price, 0), + (SELECT price FROM product_outlet_prices WHERE product_id = p.id ORDER BY updated_at DESC LIMIT 1), + NULLIF(p.price, 0), + 0 + ) as product_price, + c.id as category_id, + c.name as category_name, + c.order as category_order, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as quantity_sold, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue, + COALESCE( + SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) + / NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0), + 0) as average_price, + COUNT(DISTINCT oi.order_id) as order_count, + COALESCE(shpp.hpp_per_unit, p.cost, 0) as standard_hpp_per_unit, + COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as standard_hpp_total, + COALESCE( + SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END) + / NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0), + 0) as fifo_hpp_per_unit, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as fifo_hpp_total, + COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit, + COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as moving_average_hpp_total + `). + Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, shpp.hpp_per_unit, mahpp.hpp_per_unit"). + Order("revenue DESC"). + Scan(&detail.Products).Error + if err != nil { + return nil, err + } + + return detail, nil +} + +// GetBudgetCutOffWeekly buckets revenue and cost of goods sold into Monday-to-Sunday +// weeks. DATE_TRUNC('week') is ISO, so the buckets start on Monday, and the connection +// runs with TimeZone=Asia/Jakarta so the boundaries land on local midnight. +// A nil parentCategoryID covers every category in scope. +func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, cutOffFrom, cutOffTo time.Time) ([]*entities.BudgetCutOffWeek, error) { + var results []*entities.BudgetCutOffWeek + + query := r.db.WithContext(ctx). + Table("order_items oi"). + Select(` + DATE_TRUNC('week', o.created_at) as week_start, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue, + COUNT(DISTINCT oi.order_id) as order_count + `). + // products and categories are joined to keep the scope identical to the report + // the block is attached to, even when no parent category filter is applied + 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.is_refund = ?", false). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("oi.status != ?", entities.OrderItemStatusCancelled). + Where("o.created_at >= ? AND o.created_at <= ?", cutOffFrom, cutOffTo) + + if parentCategoryID != nil { + query = query.Where("COALESCE(c.parent_id, c.id) = ?", *parentCategoryID) + } + + query = r.resolveOutletID(query, outletID, "o.outlet_id") + + err := query. + Group("DATE_TRUNC('week', o.created_at)"). + Order("week_start ASC"). + 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/repository/category_repository.go b/internal/repository/category_repository.go index e60e923..52c9014 100644 --- a/internal/repository/category_repository.go +++ b/internal/repository/category_repository.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type CategoryRepositoryImpl struct { @@ -25,7 +26,7 @@ func (r *CategoryRepositoryImpl) Create(ctx context.Context, category *entities. func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) { var category entities.Category - err := r.db.WithContext(ctx).First(&category, "id = ?", id).Error + err := r.db.WithContext(ctx).Preload("Parent").First(&category, "id = ?", id).Error if err != nil { return nil, err } @@ -54,7 +55,8 @@ func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, business } func (r *CategoryRepositoryImpl) Update(ctx context.Context, category *entities.Category) error { - return r.db.WithContext(ctx).Save(category).Error + // Omit associations so a preloaded Parent is not upserted back over parent_id + return r.db.WithContext(ctx).Omit(clause.Associations).Save(category).Error } func (r *CategoryRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { @@ -84,7 +86,7 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in return nil, 0, err } - err := query.Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error + err := query.Preload("Parent").Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error return categories, total, err } @@ -97,6 +99,8 @@ func (r *CategoryRepositoryImpl) Count(ctx context.Context, filters map[string]i case "search": searchValue := "%" + value.(string) + "%" query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue) + case "outlet_id": + query = query.Where("outlet_id = ? OR outlet_id IS NULL", value) default: query = query.Where(key+" = ?", value) } diff --git a/internal/repository/product_repository.go b/internal/repository/product_repository.go index 3a4bff6..5fa64e5 100644 --- a/internal/repository/product_repository.go +++ b/internal/repository/product_repository.go @@ -101,6 +101,8 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int query = query.Where("price >= ?", value) case "price_max": query = query.Where("price <= ?", value) + case "category_id": + query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value)) default: query = query.Where(key+" = ?", value) } @@ -114,6 +116,15 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int return products, total, err } +// categoryAndChildrenIDs builds a subquery resolving to the category itself plus its +// direct children, so filtering by a parent category also returns the children's +// products. For a category without children it resolves to just that category. +func (r *ProductRepositoryImpl) categoryAndChildrenIDs(categoryID interface{}) *gorm.DB { + return r.db.Model(&entities.Category{}). + Select("id"). + Where("id = ? OR parent_id = ?", categoryID, categoryID) +} + func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) { var count int64 query := r.db.WithContext(ctx).Model(&entities.Product{}) @@ -127,6 +138,8 @@ func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]in query = query.Where("price >= ?", value) case "price_max": query = query.Where("price <= ?", value) + case "category_id": + query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value)) default: query = query.Where(key+" = ?", value) } @@ -232,6 +245,8 @@ func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters query = query.Where("products.price >= ?", value) case "price_max": query = query.Where("products.price <= ?", value) + case "category_id": + query = query.Where("products.category_id IN (?)", r.categoryAndChildrenIDs(value)) default: query = query.Where("products."+key+" = ?", value) } diff --git a/internal/router/router.go b/internal/router/router.go index 4f32980..26c1dae 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -335,6 +335,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics) analytics.GET("/products", r.analyticsHandler.GetProductAnalytics) analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory) + analytics.GET("/parent-categories", r.analyticsHandler.GetProductAnalyticsPerParentCategory) + analytics.GET("/parent-categories/:parent_category_id", r.analyticsHandler.GetParentCategoryAnalyticsDetail) analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics) analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod) diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 740aaf0..889b766 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -16,6 +16,8 @@ type AnalyticsService interface { GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) + GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) + GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) @@ -104,6 +106,36 @@ func (s *AnalyticsServiceImpl) GetProductAnalyticsPerCategory(ctx context.Contex return response, nil } +func (s *AnalyticsServiceImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) { + // Validate request + if err := s.validateProductAnalyticsPerParentCategoryRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + // Process analytics request + response, err := s.analyticsProcessor.GetProductAnalyticsPerParentCategory(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get product analytics per parent category: %w", err) + } + + return response, nil +} + +func (s *AnalyticsServiceImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) { + // Validate request + if err := s.validateParentCategoryAnalyticsDetailRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + // Process analytics request + response, err := s.analyticsProcessor.GetParentCategoryAnalyticsDetail(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get parent category analytics detail: %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 { @@ -253,6 +285,50 @@ func (s *AnalyticsServiceImpl) validateProductAnalyticsPerCategoryRequest(req *m return nil } +func (s *AnalyticsServiceImpl) validateProductAnalyticsPerParentCategoryRequest(req *models.ProductAnalyticsPerParentCategoryRequest) 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) validateParentCategoryAnalyticsDetailRequest(req *models.ParentCategoryAnalyticsDetailRequest) error { + if req.OrganizationID == uuid.Nil { + return fmt.Errorf("organization ID is required") + } + + if req.ParentCategoryID == uuid.Nil { + return fmt.Errorf("parent category 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/service/analytics_service_test.go b/internal/service/analytics_service_test.go index fa4db25..e300d77 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -33,6 +33,14 @@ func (analyticsProcessorStub) GetProductAnalyticsPerCategory(context.Context, *m return nil, nil } +func (analyticsProcessorStub) GetProductAnalyticsPerParentCategory(context.Context, *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) { + return nil, nil +} + +func (analyticsProcessorStub) GetParentCategoryAnalyticsDetail(context.Context, *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) { + return nil, nil +} + func (analyticsProcessorStub) GetDashboardAnalytics(context.Context, *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) { return nil, nil } diff --git a/internal/service/category_service.go b/internal/service/category_service.go index 5ee4c58..21c0c00 100644 --- a/internal/service/category_service.go +++ b/internal/service/category_service.go @@ -91,6 +91,9 @@ func (s *CategoryServiceImpl) ListCategories(ctx context.Context, req *contract. if req.BusinessType != "" { filters["business_type"] = req.BusinessType } + if req.ParentID != nil { + filters["parent_id"] = *req.ParentID + } if req.Search != "" { filters["search"] = req.Search } diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 69594a8..14d008f 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -347,6 +347,195 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer } } +// ProductAnalyticsPerParentCategoryContractToModel converts contract request to model +func ProductAnalyticsPerParentCategoryContractToModel(req *contract.ProductAnalyticsPerParentCategoryRequest) *models.ProductAnalyticsPerParentCategoryRequest { + 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.ProductAnalyticsPerParentCategoryRequest{ + OrganizationID: req.OrganizationID, + OutletID: parseOutletID(req.OutletID), + DateFrom: dateFrom, + DateTo: dateTo, + } +} + +// ProductAnalyticsPerParentCategoryModelToContract converts model response to contract +func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyticsPerParentCategoryResponse) *contract.ProductAnalyticsPerParentCategoryResponse { + if resp == nil { + return nil + } + + var data []contract.ProductAnalyticsPerParentCategoryData + for _, item := range resp.Data { + data = append(data, contract.ProductAnalyticsPerParentCategoryData{ + ParentCategoryID: item.ParentCategoryID, + ParentCategoryName: item.ParentCategoryName, + TotalRevenue: item.TotalRevenue, + TotalQuantity: item.TotalQuantity, + CategoryCount: item.CategoryCount, + ProductCount: item.ProductCount, + OrderCount: item.OrderCount, + TotalStandardHpp: item.TotalStandardHpp, + TotalFifoHpp: item.TotalFifoHpp, + TotalMovingAverageHpp: item.TotalMovingAverageHpp, + }) + } + + return &contract.ProductAnalyticsPerParentCategoryResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + OutletName: resp.OutletName, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, + Data: data, + Budget: BudgetCutOffModelToContract(resp.Budget), + } +} + +// budgetPeriodModelToContract converts one budget period to contract +func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod { + return contract.BudgetPeriod{ + PeriodStart: period.PeriodStart, + PeriodEnd: period.PeriodEnd, + Revenue: period.Revenue, + OrderCount: period.OrderCount, + LimitPurchase: period.LimitPurchase, + LimitOwner: period.LimitOwner, + LimitTeam: period.LimitTeam, + } +} + +// BudgetCutOffModelToContract converts the budget cut-off block to contract +func BudgetCutOffModelToContract(budget models.BudgetCutOff) contract.BudgetCutOff { + weekly := make([]contract.BudgetPeriod, 0, len(budget.Weekly)) + for _, week := range budget.Weekly { + weekly = append(weekly, budgetPeriodModelToContract(week)) + } + + monthly := make([]contract.BudgetMonthPeriod, 0, len(budget.Monthly)) + for _, month := range budget.Monthly { + monthly = append(monthly, contract.BudgetMonthPeriod{ + Month: month.Month, + WeekCount: month.WeekCount, + BudgetPeriod: budgetPeriodModelToContract(month.BudgetPeriod), + }) + } + + return contract.BudgetCutOff{ + Percentages: contract.BudgetPercentages{ + Purchase: budget.Percentages.Purchase, + Owner: budget.Percentages.Owner, + Team: budget.Percentages.Team, + }, + CutOffFrom: budget.CutOffFrom, + CutOffTo: budget.CutOffTo, + Total: budgetPeriodModelToContract(budget.Total), + Weekly: weekly, + Monthly: monthly, + } +} + +// ParentCategoryAnalyticsDetailContractToModel converts contract request to model +func ParentCategoryAnalyticsDetailContractToModel(req *contract.ParentCategoryAnalyticsDetailRequest) *models.ParentCategoryAnalyticsDetailRequest { + 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 + } + } + + // An unparseable id stays uuid.Nil and is rejected by the service validator + parentCategoryID, _ := uuid.Parse(req.ParentCategoryID) + + return &models.ParentCategoryAnalyticsDetailRequest{ + OrganizationID: req.OrganizationID, + ParentCategoryID: parentCategoryID, + OutletID: parseOutletID(req.OutletID), + DateFrom: dateFrom, + DateTo: dateTo, + } +} + +// ParentCategoryAnalyticsDetailModelToContract converts model response to contract +func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAnalyticsDetailResponse) *contract.ParentCategoryAnalyticsDetailResponse { + if resp == nil { + return nil + } + + categories := make([]contract.ParentCategoryAnalyticsDetailData, 0, len(resp.Categories)) + for _, category := range resp.Categories { + products := make([]contract.ParentCategoryAnalyticsProductData, 0, len(category.Products)) + for _, product := range category.Products { + products = append(products, contract.ParentCategoryAnalyticsProductData{ + ProductID: product.ProductID, + ProductName: product.ProductName, + ProductSku: product.ProductSku, + ProductPrice: product.ProductPrice, + QuantitySold: product.QuantitySold, + Revenue: product.Revenue, + AveragePrice: product.AveragePrice, + OrderCount: product.OrderCount, + StandardHppPerUnit: product.StandardHppPerUnit, + StandardHppTotal: product.StandardHppTotal, + FifoHppPerUnit: product.FifoHppPerUnit, + FifoHppTotal: product.FifoHppTotal, + MovingAverageHppPerUnit: product.MovingAverageHppPerUnit, + MovingAverageHppTotal: product.MovingAverageHppTotal, + }) + } + + categories = append(categories, contract.ParentCategoryAnalyticsDetailData{ + CategoryID: category.CategoryID, + CategoryName: category.CategoryName, + TotalRevenue: category.TotalRevenue, + TotalQuantity: category.TotalQuantity, + ProductCount: category.ProductCount, + OrderCount: category.OrderCount, + TotalStandardHpp: category.TotalStandardHpp, + TotalFifoHpp: category.TotalFifoHpp, + TotalMovingAverageHpp: category.TotalMovingAverageHpp, + Products: products, + }) + } + + return &contract.ParentCategoryAnalyticsDetailResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + OutletName: resp.OutletName, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, + ParentCategoryID: resp.ParentCategoryID, + ParentCategoryName: resp.ParentCategoryName, + Summary: contract.ParentCategoryAnalyticsDetailSummary{ + TotalRevenue: resp.Summary.TotalRevenue, + TotalQuantity: resp.Summary.TotalQuantity, + CategoryCount: resp.Summary.CategoryCount, + ProductCount: resp.Summary.ProductCount, + OrderCount: resp.Summary.OrderCount, + TotalStandardHpp: resp.Summary.TotalStandardHpp, + TotalFifoHpp: resp.Summary.TotalFifoHpp, + TotalMovingAverageHpp: resp.Summary.TotalMovingAverageHpp, + }, + Categories: categories, + Budget: BudgetCutOffModelToContract(resp.Budget), + } +} + // DashboardAnalyticsContractToModel converts contract request to model func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest { var dateFrom, dateTo time.Time diff --git a/internal/transformer/category_transformer.go b/internal/transformer/category_transformer.go index b2dbc1e..e20b831 100644 --- a/internal/transformer/category_transformer.go +++ b/internal/transformer/category_transformer.go @@ -14,6 +14,7 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C return &models.CreateCategoryRequest{ OrganizationID: apctx.OrganizationID, OutletID: req.OutletID, + ParentID: req.ParentID, Name: req.Name, Description: req.Description, ImageURL: nil, @@ -27,6 +28,7 @@ func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.U Description: req.Description, ImageURL: nil, OutletID: req.OutletID, + ParentID: req.ParentID, Order: req.Order, IsActive: nil, } @@ -41,6 +43,8 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat ID: cat.ID, OrganizationID: cat.OrganizationID, OutletID: cat.OutletID, + ParentID: cat.ParentID, + ParentName: cat.ParentName, Name: cat.Name, Description: cat.Description, BusinessType: "restaurant", diff --git a/internal/validator/category_validator.go b/internal/validator/category_validator.go index cb28905..03afa9a 100644 --- a/internal/validator/category_validator.go +++ b/internal/validator/category_validator.go @@ -59,7 +59,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda } // At least one field should be provided for update - if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.Metadata == nil { + if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil { return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode } diff --git a/migrations/000084_add_parent_id_to_categories.down.sql b/migrations/000084_add_parent_id_to_categories.down.sql new file mode 100644 index 0000000..a03301b --- /dev/null +++ b/migrations/000084_add_parent_id_to_categories.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE categories +DROP CONSTRAINT IF EXISTS categories_parent_id_fkey; +ALTER TABLE categories +DROP COLUMN IF EXISTS parent_id; + +DROP INDEX IF EXISTS idx_categories_parent_id; diff --git a/migrations/000084_add_parent_id_to_categories.up.sql b/migrations/000084_add_parent_id_to_categories.up.sql new file mode 100644 index 0000000..79dc4a9 --- /dev/null +++ b/migrations/000084_add_parent_id_to_categories.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE categories +ADD COLUMN parent_id UUID REFERENCES categories(id) ON DELETE SET NULL; + +CREATE INDEX idx_categories_parent_id ON categories(parent_id);