From 35e7152abb3c968be026a5c95cd142b862e4e61b Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 19 May 2026 14:45:26 +0700 Subject: [PATCH] add purchasing in analytics endpoint --- internal/contract/analytics_contract.go | 57 +++++++ internal/entities/analytics.go | 45 +++++ internal/handler/analytics_handler.go | 24 +++ internal/models/analytics.go | 63 +++++++ internal/processor/analytics_processor.go | 72 ++++++++ .../processor/analytics_processor_test.go | 73 +++++++++ internal/repository/analytics_repository.go | 154 ++++++++++++++++++ internal/repository/tx_manager.go | 4 + internal/router/router.go | 1 + internal/service/analytics_service.go | 50 ++++++ internal/service/analytics_service_test.go | 121 ++++++++++++++ internal/service/order_service.go | 2 +- internal/service/order_service_table_test.go | 13 ++ internal/transformer/analytics_transformer.go | 85 ++++++++++ .../transformer/analytics_transformer_test.go | 76 +++++++++ 15 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 internal/processor/analytics_processor_test.go create mode 100644 internal/service/analytics_service_test.go create mode 100644 internal/transformer/analytics_transformer_test.go diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index cc553a0..5389240 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -83,6 +83,63 @@ type SalesAnalyticsData struct { NetSales float64 `json:"net_sales"` } +type PurchasingAnalyticsRequest struct { + OrganizationID uuid.UUID + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` +} + +type PurchasingAnalyticsResponse 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"` + GroupBy string `json:"group_by"` + Summary PurchasingSummary `json:"summary"` + Data []PurchasingAnalyticsData `json:"data"` + IngredientData []PurchasingIngredientData `json:"ingredient_data"` + VendorData []PurchasingVendorData `json:"vendor_data"` +} + +type PurchasingSummary struct { + TotalPurchases float64 `json:"total_purchases"` + TotalPurchaseOrders int64 `json:"total_purchase_orders"` + TotalQuantity float64 `json:"total_quantity"` + AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` + TotalIngredients int64 `json:"total_ingredients"` + TotalVendors int64 `json:"total_vendors"` +} + +type PurchasingAnalyticsData struct { + Date time.Time `json:"date"` + Purchases float64 `json:"purchases"` + PurchaseOrders int64 `json:"purchase_orders"` + Quantity float64 `json:"quantity"` + Ingredients int64 `json:"ingredients"` + Vendors int64 `json:"vendors"` +} + +type PurchasingIngredientData struct { + IngredientID uuid.UUID `json:"ingredient_id"` + IngredientName string `json:"ingredient_name"` + Quantity float64 `json:"quantity"` + TotalCost float64 `json:"total_cost"` + AverageUnitCost float64 `json:"average_unit_cost"` + PurchaseOrderCount int64 `json:"purchase_order_count"` +} + +type PurchasingVendorData struct { + VendorID uuid.UUID `json:"vendor_id"` + VendorName string `json:"vendor_name"` + TotalCost float64 `json:"total_cost"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + IngredientCount int64 `json:"ingredient_count"` + Quantity float64 `json:"quantity"` +} + // ProductAnalyticsRequest represents the request for product analytics type ProductAnalyticsRequest struct { OrganizationID uuid.UUID diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 5fae6fe..2b69ae7 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -27,6 +27,51 @@ type SalesAnalytics struct { NetSales float64 `json:"net_sales"` } +// PurchasingAnalytics represents purchasing analytics data +type PurchasingAnalytics struct { + OutletName *string `json:"outlet_name,omitempty"` + Summary PurchasingSummary `json:"summary"` + Data []PurchasingAnalyticsData `json:"data"` + IngredientData []PurchasingIngredientData `json:"ingredient_data"` + VendorData []PurchasingVendorData `json:"vendor_data"` +} + +type PurchasingSummary struct { + TotalPurchases float64 `json:"total_purchases"` + TotalPurchaseOrders int64 `json:"total_purchase_orders"` + TotalQuantity float64 `json:"total_quantity"` + AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` + TotalIngredients int64 `json:"total_ingredients"` + TotalVendors int64 `json:"total_vendors"` +} + +type PurchasingAnalyticsData struct { + Date time.Time `json:"date"` + Purchases float64 `json:"purchases"` + PurchaseOrders int64 `json:"purchase_orders"` + Quantity float64 `json:"quantity"` + Ingredients int64 `json:"ingredients"` + Vendors int64 `json:"vendors"` +} + +type PurchasingIngredientData struct { + IngredientID uuid.UUID `json:"ingredient_id"` + IngredientName string `json:"ingredient_name"` + Quantity float64 `json:"quantity"` + TotalCost float64 `json:"total_cost"` + AverageUnitCost float64 `json:"average_unit_cost"` + PurchaseOrderCount int64 `json:"purchase_order_count"` +} + +type PurchasingVendorData struct { + VendorID uuid.UUID `json:"vendor_id"` + VendorName string `json:"vendor_name"` + TotalCost float64 `json:"total_cost"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + IngredientCount int64 `json:"ingredient_count"` + Quantity float64 `json:"quantity"` +} + type ProductAnalytics struct { ProductID uuid.UUID `json:"product_id"` ProductName string `json:"product_name"` diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go index d40ae33..a8945e0 100644 --- a/internal/handler/analytics_handler.go +++ b/internal/handler/analytics_handler.go @@ -85,6 +85,30 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetSalesAnalytics") } +func (h *AnalyticsHandler) GetPurchasingAnalytics(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.PurchasingAnalyticsRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics") + return + } + + req.OrganizationID = contextInfo.OrganizationID + req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) + modelReq := transformer.PurchasingAnalyticsContractToModel(&req) + + response, err := h.analyticsService.GetPurchasingAnalytics(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics") + return + } + + contractResp := transformer.PurchasingAnalyticsModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetPurchasingAnalytics") +} + func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) { ctx := c.Request.Context() contextInfo := appcontext.FromGinContext(ctx) diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 59b8445..2821518 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -87,6 +87,69 @@ type SalesAnalyticsData struct { NetSales float64 `json:"net_sales"` } +// PurchasingAnalyticsRequest represents the request for purchasing analytics +type PurchasingAnalyticsRequest struct { + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` + GroupBy string `validate:"omitempty,oneof=day hour week month"` +} + +// PurchasingAnalyticsResponse represents the response for purchasing analytics +type PurchasingAnalyticsResponse 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"` + GroupBy string `json:"group_by"` + Summary PurchasingSummary `json:"summary"` + Data []PurchasingAnalyticsData `json:"data"` + IngredientData []PurchasingIngredientData `json:"ingredient_data"` + VendorData []PurchasingVendorData `json:"vendor_data"` +} + +// PurchasingSummary represents the summary of purchasing analytics +type PurchasingSummary struct { + TotalPurchases float64 `json:"total_purchases"` + TotalPurchaseOrders int64 `json:"total_purchase_orders"` + TotalQuantity float64 `json:"total_quantity"` + AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` + TotalIngredients int64 `json:"total_ingredients"` + TotalVendors int64 `json:"total_vendors"` +} + +// PurchasingAnalyticsData represents purchasing analytics by time period +type PurchasingAnalyticsData struct { + Date time.Time `json:"date"` + Purchases float64 `json:"purchases"` + PurchaseOrders int64 `json:"purchase_orders"` + Quantity float64 `json:"quantity"` + Ingredients int64 `json:"ingredients"` + Vendors int64 `json:"vendors"` +} + +// PurchasingIngredientData represents purchasing analytics for an ingredient +type PurchasingIngredientData struct { + IngredientID uuid.UUID `json:"ingredient_id"` + IngredientName string `json:"ingredient_name"` + Quantity float64 `json:"quantity"` + TotalCost float64 `json:"total_cost"` + AverageUnitCost float64 `json:"average_unit_cost"` + PurchaseOrderCount int64 `json:"purchase_order_count"` +} + +// PurchasingVendorData represents purchasing analytics for a vendor +type PurchasingVendorData struct { + VendorID uuid.UUID `json:"vendor_id"` + VendorName string `json:"vendor_name"` + TotalCost float64 `json:"total_cost"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + IngredientCount int64 `json:"ingredient_count"` + Quantity float64 `json:"quantity"` +} + // ProductAnalyticsRequest represents the request for product analytics type ProductAnalyticsRequest struct { OrganizationID uuid.UUID `validate:"required"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index bef2ca7..344b801 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -12,6 +12,7 @@ import ( type AnalyticsProcessor interface { GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) + 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) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) @@ -164,6 +165,77 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod }, nil } +func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) { + if req.DateFrom.After(req.DateTo) { + return nil, fmt.Errorf("date_from cannot be after date_to") + } + + if req.GroupBy == "" { + req.GroupBy = "day" + } + + result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) + if err != nil { + return nil, fmt.Errorf("failed to get purchasing analytics: %w", err) + } + + data := make([]models.PurchasingAnalyticsData, len(result.Data)) + for i, item := range result.Data { + data[i] = models.PurchasingAnalyticsData{ + Date: item.Date, + Purchases: item.Purchases, + PurchaseOrders: item.PurchaseOrders, + Quantity: item.Quantity, + Ingredients: item.Ingredients, + Vendors: item.Vendors, + } + } + + ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData)) + for i, item := range result.IngredientData { + ingredientData[i] = models.PurchasingIngredientData{ + IngredientID: item.IngredientID, + IngredientName: item.IngredientName, + Quantity: item.Quantity, + TotalCost: item.TotalCost, + AverageUnitCost: item.AverageUnitCost, + PurchaseOrderCount: item.PurchaseOrderCount, + } + } + + vendorData := make([]models.PurchasingVendorData, len(result.VendorData)) + for i, item := range result.VendorData { + vendorData[i] = models.PurchasingVendorData{ + VendorID: item.VendorID, + VendorName: item.VendorName, + TotalCost: item.TotalCost, + PurchaseOrderCount: item.PurchaseOrderCount, + IngredientCount: item.IngredientCount, + Quantity: item.Quantity, + } + } + + return &models.PurchasingAnalyticsResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + OutletName: result.OutletName, + DateFrom: req.DateFrom, + DateTo: req.DateTo, + GroupBy: req.GroupBy, + Summary: models.PurchasingSummary{ + TotalPurchases: result.Summary.TotalPurchases, + TotalPurchaseOrders: result.Summary.TotalPurchaseOrders, + TotalQuantity: result.Summary.TotalQuantity, + AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, + TotalIngredients: result.Summary.TotalIngredients, + TotalVendors: result.Summary.TotalVendors, + }, + Data: data, + IngredientData: ingredientData, + VendorData: vendorData, + }, nil +} + func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, 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 new file mode 100644 index 0000000..cad6e01 --- /dev/null +++ b/internal/processor/analytics_processor_test.go @@ -0,0 +1,73 @@ +package processor + +import ( + "context" + "testing" + "time" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +type analyticsRepositoryStub struct { + purchasingResult *entities.PurchasingAnalytics +} + +func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { + return nil, nil +} + +func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) { + return nil, nil +} + +func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) { + return s.purchasingResult, nil +} + +func (analyticsRepositoryStub) GetProductAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, int) ([]*entities.ProductAnalytics, error) { + return nil, nil +} + +func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerCategory, error) { + return nil, nil +} + +func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) { + return nil, nil +} + +func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ProfitLossAnalytics, error) { + return nil, nil +} + +func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) { + outletID := uuid.New() + outletName := "Main Outlet" + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + purchasingResult: &entities.PurchasingAnalytics{ + OutletName: &outletName, + Summary: entities.PurchasingSummary{ + TotalPurchases: 125, + }, + }, + }) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + OutletID: &outletID, + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, &outletID, result.OutletID) + require.NotNil(t, result.OutletName) + require.Equal(t, outletName, *result.OutletName) + require.Equal(t, float64(125), result.Summary.TotalPurchases) +} diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index d25b487..9c2a0cf 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -13,6 +13,7 @@ import ( type AnalyticsRepository interface { GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) GetSalesAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.SalesAnalytics, error) + 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) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) @@ -122,6 +123,159 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz return results, err } +func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { + var summary entities.PurchasingSummary + var outletName *string + + if outletID != nil { + var outlet struct { + Name string + } + result := r.db.WithContext(ctx). + Table("outlets"). + Select("name"). + Where("id = ? AND organization_id = ?", *outletID, organizationID). + Limit(1). + Scan(&outlet) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected > 0 { + outletName = &outlet.Name + } + } + + summaryQuery := r.db.WithContext(ctx). + Table("inventory_movements im"). + Select(` + COALESCE(SUM(im.total_cost), 0) as total_purchases, + COUNT(DISTINCT im.reference_id) as total_purchase_orders, + COALESCE(SUM(im.quantity), 0) as total_quantity, + CASE + WHEN COUNT(DISTINCT im.reference_id) > 0 + THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id) + ELSE 0 + END as average_purchase_order_value, + COUNT(DISTINCT im.item_id) as total_ingredients, + COUNT(DISTINCT po.vendor_id) as total_vendors + `). + Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). + Where("im.organization_id = ?", organizationID). + Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). + Where("im.item_type = ?", "INGREDIENT"). + Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). + Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo) + + summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id") + + if err := summaryQuery.Scan(&summary).Error; err != nil { + return nil, err + } + + var dateFormat string + switch groupBy { + case "hour": + dateFormat = "DATE_TRUNC('hour', im.created_at)" + case "week": + dateFormat = "DATE_TRUNC('week', im.created_at)" + case "month": + dateFormat = "DATE_TRUNC('month', im.created_at)" + default: + dateFormat = "DATE_TRUNC('day', im.created_at)" + } + + var data []entities.PurchasingAnalyticsData + dataQuery := r.db.WithContext(ctx). + Table("inventory_movements im"). + Select(` + `+dateFormat+` as date, + COALESCE(SUM(im.total_cost), 0) as purchases, + COUNT(DISTINCT im.reference_id) as purchase_orders, + COALESCE(SUM(im.quantity), 0) as quantity, + COUNT(DISTINCT im.item_id) as ingredients, + COUNT(DISTINCT po.vendor_id) as vendors + `). + Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). + Where("im.organization_id = ?", organizationID). + Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). + Where("im.item_type = ?", "INGREDIENT"). + Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). + Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). + Group(dateFormat). + Order(dateFormat) + + dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id") + + if err := dataQuery.Scan(&data).Error; err != nil { + return nil, err + } + + var ingredientData []entities.PurchasingIngredientData + ingredientQuery := r.db.WithContext(ctx). + Table("inventory_movements im"). + Select(` + i.id as ingredient_id, + i.name as ingredient_name, + COALESCE(SUM(im.quantity), 0) as quantity, + COALESCE(SUM(im.total_cost), 0) as total_cost, + CASE + WHEN SUM(im.quantity) > 0 + THEN COALESCE(SUM(im.total_cost), 0) / SUM(im.quantity) + ELSE 0 + END as average_unit_cost, + COUNT(DISTINCT im.reference_id) as purchase_order_count + `). + Joins("JOIN ingredients i ON im.item_id = i.id"). + Where("im.organization_id = ?", organizationID). + Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). + Where("im.item_type = ?", "INGREDIENT"). + Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). + Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). + Group("i.id, i.name"). + Order("total_cost DESC") + + ingredientQuery = r.resolveOutletID(ingredientQuery, outletID, "im.outlet_id") + + if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { + return nil, err + } + + var vendorData []entities.PurchasingVendorData + vendorQuery := r.db.WithContext(ctx). + Table("inventory_movements im"). + Select(` + v.id as vendor_id, + v.name as vendor_name, + COALESCE(SUM(im.total_cost), 0) as total_cost, + COUNT(DISTINCT im.reference_id) as purchase_order_count, + COUNT(DISTINCT im.item_id) as ingredient_count, + COALESCE(SUM(im.quantity), 0) as quantity + `). + Joins("JOIN purchase_orders po ON im.reference_id = po.id"). + Joins("JOIN vendors v ON po.vendor_id = v.id"). + Where("im.organization_id = ?", organizationID). + Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). + Where("im.item_type = ?", "INGREDIENT"). + Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). + Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). + Group("v.id, v.name"). + Order("total_cost DESC") + + vendorQuery = r.resolveOutletID(vendorQuery, outletID, "im.outlet_id") + + if err := vendorQuery.Scan(&vendorData).Error; err != nil { + return nil, err + } + + return &entities.PurchasingAnalytics{ + OutletName: outletName, + Summary: summary, + Data: data, + IngredientData: ingredientData, + VendorData: vendorData, + }, nil +} + func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { var results []*entities.ProductAnalytics diff --git a/internal/repository/tx_manager.go b/internal/repository/tx_manager.go index 94a19e8..243a43a 100644 --- a/internal/repository/tx_manager.go +++ b/internal/repository/tx_manager.go @@ -28,6 +28,10 @@ func NewTxManager(db *gorm.DB) *TxManager { return &TxManager{db: db} } // WithTransaction runs fn inside a DB transaction, injecting the *gorm.DB tx into ctx. func (m *TxManager) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error { + if m == nil || m.db == nil { + return fn(ctx) + } + return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { ctxTx := context.WithValue(ctx, txKey, tx) return fn(ctxTx) diff --git a/internal/router/router.go b/internal/router/router.go index bebbb89..96d29a0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -325,6 +325,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { { analytics.GET("/payment-methods", r.analyticsHandler.GetPaymentMethodAnalytics) analytics.GET("/sales", r.analyticsHandler.GetSalesAnalytics) + analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics) analytics.GET("/products", r.analyticsHandler.GetProductAnalytics) analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory) analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics) diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 6f0d85a..7643c3e 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -13,6 +13,7 @@ import ( type AnalyticsService interface { GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) + 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) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) @@ -57,6 +58,19 @@ func (s *AnalyticsServiceImpl) GetSalesAnalytics(ctx context.Context, req *model return response, nil } +func (s *AnalyticsServiceImpl) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) { + if err := s.validatePurchasingAnalyticsRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + response, err := s.analyticsProcessor.GetPurchasingAnalytics(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get purchasing analytics: %w", err) + } + + return response, nil +} + func (s *AnalyticsServiceImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) { // Validate request if err := s.validateProductAnalyticsRequest(req); err != nil { @@ -168,6 +182,42 @@ func (s *AnalyticsServiceImpl) validateSalesAnalyticsRequest(req *models.SalesAn return nil } +func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.PurchasingAnalyticsRequest) error { + if req == nil { + return fmt.Errorf("request cannot be nil") + } + + 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") + } + + if req.GroupBy != "" { + validGroupBy := map[string]bool{ + "day": true, + "hour": true, + "week": true, + "month": true, + } + if !validGroupBy[req.GroupBy] { + return fmt.Errorf("invalid group_by value: %s", req.GroupBy) + } + } + + return nil +} + func (s *AnalyticsServiceImpl) validateProductAnalyticsRequest(req *models.ProductAnalyticsRequest) 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 new file mode 100644 index 0000000..665578d --- /dev/null +++ b/internal/service/analytics_service_test.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "testing" + "time" + + "apskel-pos-be/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +type analyticsProcessorStub struct{} + +func (analyticsProcessorStub) GetPaymentMethodAnalytics(context.Context, *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) { + return nil, nil +} + +func (analyticsProcessorStub) GetSalesAnalytics(context.Context, *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) { + return nil, nil +} + +func (analyticsProcessorStub) GetPurchasingAnalytics(context.Context, *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) { + return &models.PurchasingAnalyticsResponse{}, nil +} + +func (analyticsProcessorStub) GetProductAnalytics(context.Context, *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) { + return nil, nil +} + +func (analyticsProcessorStub) GetProductAnalyticsPerCategory(context.Context, *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) { + return nil, nil +} + +func (analyticsProcessorStub) GetDashboardAnalytics(context.Context, *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) { + return nil, nil +} + +func (analyticsProcessorStub) GetProfitLossAnalytics(context.Context, *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) { + return nil, nil +} + +func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { + service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + req *models.PurchasingAnalyticsRequest + wantErr string + }{ + { + name: "missing organization", + req: &models.PurchasingAnalyticsRequest{ + DateFrom: now, + DateTo: now, + }, + wantErr: "organization ID is required", + }, + { + name: "missing date_from", + req: &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateTo: now, + }, + wantErr: "date_from is required", + }, + { + name: "missing date_to", + req: &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + }, + wantErr: "date_to is required", + }, + { + name: "reversed dates", + req: &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now.AddDate(0, 0, 1), + DateTo: now, + }, + wantErr: "date_from cannot be after date_to", + }, + { + name: "invalid group_by", + req: &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + GroupBy: "quarter", + }, + wantErr: "invalid group_by value: quarter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := service.GetPurchasingAnalytics(context.Background(), tt.req) + + require.Nil(t, resp) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T) { + service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + resp, err := service.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.NotNil(t, resp) +} diff --git a/internal/service/order_service.go b/internal/service/order_service.go index f205d2d..84906c3 100644 --- a/internal/service/order_service.go +++ b/internal/service/order_service.go @@ -199,7 +199,7 @@ func (s *OrderServiceImpl) createIngredientTransactions(ctx context.Context, ord // Calculate waste quantities transactions, err := s.calculateWasteQuantities(productRecipes, float64(orderItem.Quantity)) if err != nil { - return nil, fmt.Errorf("failed to calculate waste quantities for product %s: %w", err) + return nil, fmt.Errorf("failed to calculate waste quantities for product %s: %w", orderItem.ProductID, err) } // Set common fields for all transactions diff --git a/internal/service/order_service_table_test.go b/internal/service/order_service_table_test.go index e961712..66fd43b 100644 --- a/internal/service/order_service_table_test.go +++ b/internal/service/order_service_table_test.go @@ -114,6 +114,14 @@ func (m *MockTableRepository) GetByID(ctx context.Context, id uuid.UUID) (*entit return args.Get(0).(*entities.Table), args.Error(1) } +func (m *MockTableRepository) GetByToken(ctx context.Context, token string) (*entities.Table, error) { + args := m.Called(ctx, token) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*entities.Table), args.Error(1) +} + func (m *MockTableRepository) GetByOutletID(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) { args := m.Called(ctx, outletID) if args.Get(0) == nil { @@ -182,6 +190,11 @@ func (m *MockTableRepository) GetByOrderID(ctx context.Context, orderID uuid.UUI return args.Get(0).(*entities.Table), args.Error(1) } +func (m *MockTableRepository) UpdateToken(ctx context.Context, tableID uuid.UUID, token string) error { + args := m.Called(ctx, tableID, token) + return args.Error(0) +} + func TestCreateOrderWithTableOccupation(t *testing.T) { // Setup ctx := context.Background() diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 018dfdf..52105e2 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -138,6 +138,91 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac } } +// PurchasingAnalyticsContractToModel converts contract request to model +func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest) *models.PurchasingAnalyticsRequest { + var dateFrom, dateTo time.Time + + if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil { + if fromTime != nil { + dateFrom = *fromTime + } + if toTime != nil { + dateTo = *toTime + } + } + + return &models.PurchasingAnalyticsRequest{ + OrganizationID: req.OrganizationID, + OutletID: parseOutletID(req.OutletID), + DateFrom: dateFrom, + DateTo: dateTo, + GroupBy: req.GroupBy, + } +} + +// PurchasingAnalyticsModelToContract converts model response to contract +func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse) *contract.PurchasingAnalyticsResponse { + if resp == nil { + return nil + } + + data := make([]contract.PurchasingAnalyticsData, len(resp.Data)) + for i, item := range resp.Data { + data[i] = contract.PurchasingAnalyticsData{ + Date: item.Date, + Purchases: item.Purchases, + PurchaseOrders: item.PurchaseOrders, + Quantity: item.Quantity, + Ingredients: item.Ingredients, + Vendors: item.Vendors, + } + } + + ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData)) + for i, item := range resp.IngredientData { + ingredientData[i] = contract.PurchasingIngredientData{ + IngredientID: item.IngredientID, + IngredientName: item.IngredientName, + Quantity: item.Quantity, + TotalCost: item.TotalCost, + AverageUnitCost: item.AverageUnitCost, + PurchaseOrderCount: item.PurchaseOrderCount, + } + } + + vendorData := make([]contract.PurchasingVendorData, len(resp.VendorData)) + for i, item := range resp.VendorData { + vendorData[i] = contract.PurchasingVendorData{ + VendorID: item.VendorID, + VendorName: item.VendorName, + TotalCost: item.TotalCost, + PurchaseOrderCount: item.PurchaseOrderCount, + IngredientCount: item.IngredientCount, + Quantity: item.Quantity, + } + } + + return &contract.PurchasingAnalyticsResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + OutletName: resp.OutletName, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, + GroupBy: resp.GroupBy, + Summary: contract.PurchasingSummary{ + TotalPurchases: resp.Summary.TotalPurchases, + TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders, + TotalQuantity: resp.Summary.TotalQuantity, + AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, + TotalIngredients: resp.Summary.TotalIngredients, + TotalVendors: resp.Summary.TotalVendors, + }, + Data: data, + IngredientData: ingredientData, + VendorData: vendorData, + } +} + // ProductAnalyticsContractToModel converts contract request to model func ProductAnalyticsContractToModel(req *contract.ProductAnalyticsRequest) *models.ProductAnalyticsRequest { var dateFrom, dateTo time.Time diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go new file mode 100644 index 0000000..0c94fbb --- /dev/null +++ b/internal/transformer/analytics_transformer_test.go @@ -0,0 +1,76 @@ +package transformer + +import ( + "encoding/json" + "testing" + "time" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestPurchasingAnalyticsContractToModelParsesDateRangeAndOutlet(t *testing.T) { + orgID := uuid.New() + outletID := uuid.New().String() + + req := &contract.PurchasingAnalyticsRequest{ + OrganizationID: orgID, + OutletID: &outletID, + DateFrom: "01-05-2026", + DateTo: "02-05-2026", + GroupBy: "week", + } + + result := PurchasingAnalyticsContractToModel(req) + + require.Equal(t, orgID, result.OrganizationID) + require.NotNil(t, result.OutletID) + require.Equal(t, outletID, result.OutletID.String()) + require.Equal(t, "week", result.GroupBy) + + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.DateFrom) + require.Equal(t, time.Date(2026, 5, 2, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo) +} + +func TestPurchasingAnalyticsContractToModelIgnoresInvalidOutlet(t *testing.T) { + outletID := "not-a-uuid" + + result := PurchasingAnalyticsContractToModel(&contract.PurchasingAnalyticsRequest{ + OutletID: &outletID, + DateFrom: "01-05-2026", + DateTo: "02-05-2026", + }) + + require.Nil(t, result.OutletID) +} + +func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { + outletID := uuid.New() + outletName := "Main Outlet" + + result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ + OrganizationID: uuid.New(), + OutletID: &outletID, + OutletName: &outletName, + }) + + require.NotNil(t, result) + require.Equal(t, &outletID, result.OutletID) + require.NotNil(t, result.OutletName) + require.Equal(t, outletName, *result.OutletName) +} + +func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { + result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ + OrganizationID: uuid.New(), + }) + + payload, err := json.Marshal(result) + require.NoError(t, err) + require.NotContains(t, string(payload), "outlet_name") +} -- 2.47.2