From fbc65c560642af974c409e098951f50cb488a128 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 24 Jun 2026 16:30:40 +0700 Subject: [PATCH] Add grouping by outlet_id --- internal/contract/analytics_contract.go | 17 +++- internal/entities/analytics.go | 33 +++++-- internal/models/analytics.go | 29 ++++-- internal/processor/analytics_processor.go | 18 ++++ .../processor/analytics_processor_test.go | 46 +++++++++ internal/repository/analytics_repository.go | 95 +++++++++++++------ internal/service/analytics_service.go | 9 +- internal/service/analytics_service_test.go | 15 +++ internal/transformer/analytics_transformer.go | 18 ++++ .../transformer/analytics_transformer_test.go | 34 +++++++ 10 files changed, 263 insertions(+), 51 deletions(-) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index f02570b..6bcb29c 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -90,7 +90,7 @@ type PurchasingAnalyticsRequest struct { OutletID *string `form:"outlet_id,omitempty"` DateFrom string `form:"date_from" validate:"required"` DateTo string `form:"date_to" validate:"required"` - GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month outlet_id"` } type PurchasingAnalyticsResponse struct { @@ -102,6 +102,7 @@ type PurchasingAnalyticsResponse struct { GroupBy string `json:"group_by"` Summary PurchasingSummary `json:"summary"` Data []PurchasingAnalyticsData `json:"data"` + OutletData []PurchasingOutletData `json:"outlet_data,omitempty"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` } @@ -132,6 +133,20 @@ type PurchasingAnalyticsData struct { Vendors int64 `json:"vendors"` } +type PurchasingOutletData struct { + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + OutletName string `json:"outlet_name"` + Purchases float64 `json:"purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrders int64 `json:"purchase_orders"` + RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` + ExpenseCount int64 `json:"expense_count"` + 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"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index f911024..d50ed99 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -32,6 +32,7 @@ type PurchasingAnalytics struct { OutletName *string `json:"outlet_name,omitempty"` Summary PurchasingSummary `json:"summary"` Data []PurchasingAnalyticsData `json:"data"` + OutletData []PurchasingOutletData `json:"outlet_data,omitempty"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` } @@ -62,6 +63,20 @@ type PurchasingAnalyticsData struct { Vendors int64 `json:"vendors"` } +type PurchasingOutletData struct { + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + OutletName string `json:"outlet_name"` + Purchases float64 `json:"purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrders int64 `json:"purchase_orders"` + RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` + ExpenseCount int64 `json:"expense_count"` + 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"` @@ -114,15 +129,15 @@ type ProductAnalyticsPerCategory struct { // DashboardOverview represents dashboard overview data type DashboardOverview struct { - TotalSales float64 `json:"total_sales"` - TotalOrders int64 `json:"total_orders"` - AverageOrderValue float64 `json:"average_order_value"` - TotalCustomers int64 `json:"total_customers"` - VoidedOrders int64 `json:"voided_orders"` - RefundedOrders int64 `json:"refunded_orders"` - TotalItemSold int64 `json:"total_item_sold"` - TotalLowStock int64 `json:"total_low_stock"` - TotalProductActive int64 `json:"total_product_active"` + TotalSales float64 `json:"total_sales"` + TotalOrders int64 `json:"total_orders"` + AverageOrderValue float64 `json:"average_order_value"` + TotalCustomers int64 `json:"total_customers"` + VoidedOrders int64 `json:"voided_orders"` + RefundedOrders int64 `json:"refunded_orders"` + TotalItemSold int64 `json:"total_item_sold"` + TotalLowStock int64 `json:"total_low_stock"` + TotalProductActive int64 `json:"total_product_active"` } type ProfitLossAnalytics struct { diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 9e32fd7..2836296 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -95,7 +95,7 @@ type PurchasingAnalyticsRequest struct { OutletID *uuid.UUID `validate:"omitempty"` DateFrom time.Time `validate:"required"` DateTo time.Time `validate:"required"` - GroupBy string `validate:"omitempty,oneof=day hour week month"` + GroupBy string `validate:"omitempty,oneof=day hour week month outlet_id"` } // PurchasingAnalyticsResponse represents the response for purchasing analytics @@ -108,6 +108,7 @@ type PurchasingAnalyticsResponse struct { GroupBy string `json:"group_by"` Summary PurchasingSummary `json:"summary"` Data []PurchasingAnalyticsData `json:"data"` + OutletData []PurchasingOutletData `json:"outlet_data,omitempty"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` } @@ -140,6 +141,20 @@ type PurchasingAnalyticsData struct { Vendors int64 `json:"vendors"` } +type PurchasingOutletData struct { + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + OutletName string `json:"outlet_name"` + Purchases float64 `json:"purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrders int64 `json:"purchase_orders"` + RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` + ExpenseCount int64 `json:"expense_count"` + 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"` @@ -288,12 +303,12 @@ type ProfitLossAnalyticsResponse struct { } type ProfitLossPurchasing struct { - TodayTotal float64 `json:"today_total"` - MtdTotal float64 `json:"mtd_total"` - TodayRawMaterial float64 `json:"today_raw_material"` - MtdRawMaterial float64 `json:"mtd_raw_material"` - TodayExpense float64 `json:"today_expense"` - MtdExpense float64 `json:"mtd_expense"` + TodayTotal float64 `json:"today_total"` + MtdTotal float64 `json:"mtd_total"` + TodayRawMaterial float64 `json:"today_raw_material"` + MtdRawMaterial float64 `json:"mtd_raw_material"` + TodayExpense float64 `json:"today_expense"` + MtdExpense float64 `json:"mtd_expense"` Items []ProfitLossPurchasingItem `json:"items"` } diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 765ba5a..378dd0f 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -218,6 +218,23 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req } } + outletData := make([]models.PurchasingOutletData, len(result.OutletData)) + for i, item := range result.OutletData { + outletData[i] = models.PurchasingOutletData{ + OutletID: item.OutletID, + OutletName: item.OutletName, + Purchases: item.Purchases, + RawMaterialPurchases: item.RawMaterialPurchases, + ExpensePurchases: item.ExpensePurchases, + PurchaseOrders: item.PurchaseOrders, + RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders, + ExpenseCount: item.ExpenseCount, + 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{ @@ -262,6 +279,7 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req TotalVendors: result.Summary.TotalVendors, }, Data: data, + OutletData: outletData, IngredientData: ingredientData, VendorData: vendorData, }, nil diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 23d4967..f5f8d40 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -145,6 +145,52 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } +func TestAnalyticsProcessorGetPurchasingAnalyticsMapsOutletData(t *testing.T) { + outletID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ + purchasingResult: &entities.PurchasingAnalytics{ + Summary: entities.PurchasingSummary{ + TotalPurchases: 500, + }, + OutletData: []entities.PurchasingOutletData{ + { + OutletID: &outletID, + OutletName: "Outlet A", + Purchases: 500, + RawMaterialPurchases: 350, + ExpensePurchases: 150, + PurchaseOrders: 4, + RawMaterialPurchaseOrders: 3, + ExpenseCount: 2, + Quantity: 10, + Ingredients: 5, + Vendors: 2, + }, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + GroupBy: "outlet_id", + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "outlet_id", result.GroupBy) + require.Empty(t, result.Data) + require.Len(t, result.OutletData, 1) + require.Equal(t, &outletID, result.OutletData[0].OutletID) + require.Equal(t, "Outlet A", result.OutletData[0].OutletName) + require.Equal(t, float64(500), result.OutletData[0].Purchases) + require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases) + require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases) + require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders) +} + func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { productID := uuid.New() categoryID := uuid.New() diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index cfa15a0..92bcf72 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -224,34 +224,68 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex } var data []entities.PurchasingAnalyticsData - dataQuery := r.db.WithContext(ctx). - Table("purchase_orders po"). - Select(` - `+dateFormat+` as date, - COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases, - COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases, - COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases, - COUNT(DISTINCT po.id) as purchase_orders, - COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders, - COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count, - COALESCE(SUM(poi.quantity), 0) as quantity, - COUNT(DISTINCT i.id) as ingredients, - COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors - `). - Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). - Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). - Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). - Joins("LEFT JOIN units u ON poi.unit_id = u.id"). - Where("po.organization_id = ?", organizationID). - Where("po.status != ?", "cancelled"). - Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). - Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). - Group(dateFormat). - Order(dateFormat) - dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID) + var outletData []entities.PurchasingOutletData + if groupBy == "outlet_id" { + outletQuery := r.db.WithContext(ctx). + Table("purchase_orders po"). + Select(` + po.outlet_id as outlet_id, + COALESCE(o.name, 'No Outlet') as outlet_name, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases, + COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases, + COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases, + COUNT(DISTINCT po.id) as purchase_orders, + COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders, + COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count, + COALESCE(SUM(poi.quantity), 0) as quantity, + COUNT(DISTINCT i.id) as ingredients, + COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors + `). + Joins("LEFT JOIN outlets o ON po.outlet_id = o.id"). + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). + Joins("LEFT JOIN units u ON poi.unit_id = u.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group("po.outlet_id, COALESCE(o.name, 'No Outlet')"). + Order("outlet_name ASC") + outletQuery = r.applyPurchaseOrderItemOutletFilter(outletQuery, outletID) - if err := dataQuery.Scan(&data).Error; err != nil { - return nil, err + if err := outletQuery.Scan(&outletData).Error; err != nil { + return nil, err + } + } else { + dataQuery := r.db.WithContext(ctx). + Table("purchase_orders po"). + Select(` + `+dateFormat+` as date, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases, + COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases, + COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases, + COUNT(DISTINCT po.id) as purchase_orders, + COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders, + COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count, + COALESCE(SUM(poi.quantity), 0) as quantity, + COUNT(DISTINCT i.id) as ingredients, + COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors + `). + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). + Joins("LEFT JOIN units u ON poi.unit_id = u.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status != ?", "cancelled"). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group(dateFormat). + Order(dateFormat) + dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID) + + if err := dataQuery.Scan(&data).Error; err != nil { + return nil, err + } } var ingredientData []entities.PurchasingIngredientData @@ -315,6 +349,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex OutletName: outletName, Summary: summary, Data: data, + OutletData: outletData, IngredientData: ingredientData, VendorData: vendorData, }, nil @@ -806,9 +841,9 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C } type purchasingTotals struct { - Total float64 - RawMaterial float64 - Expense float64 + Total float64 + RawMaterial float64 + Expense float64 } func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) { diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 740aaf0..bdc5242 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -196,10 +196,11 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu if req.GroupBy != "" { validGroupBy := map[string]bool{ - "day": true, - "hour": true, - "week": true, - "month": true, + "day": true, + "hour": true, + "week": true, + "month": true, + "outlet_id": true, } if !validGroupBy[req.GroupBy] { return fmt.Errorf("invalid group_by value: %s", req.GroupBy) diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index fa4db25..9adf4f8 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -132,6 +132,21 @@ func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T) require.NotNil(t, resp) } +func TestAnalyticsServiceGetPurchasingAnalyticsAllowsOutletGroupBy(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, + GroupBy: "outlet_id", + }) + + require.NoError(t, err) + require.NotNil(t, resp) +} + func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) { service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 69594a8..0d92d9e 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -184,6 +184,23 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse } } + outletData := make([]contract.PurchasingOutletData, len(resp.OutletData)) + for i, item := range resp.OutletData { + outletData[i] = contract.PurchasingOutletData{ + OutletID: item.OutletID, + OutletName: item.OutletName, + Purchases: item.Purchases, + RawMaterialPurchases: item.RawMaterialPurchases, + ExpensePurchases: item.ExpensePurchases, + PurchaseOrders: item.PurchaseOrders, + RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders, + ExpenseCount: item.ExpenseCount, + 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{ @@ -228,6 +245,7 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse TotalVendors: resp.Summary.TotalVendors, }, Data: data, + OutletData: outletData, IngredientData: ingredientData, VendorData: vendorData, } diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 4d1327e..a9e316e 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -105,6 +105,40 @@ func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { require.NotContains(t, string(payload), "outlet_name") } +func TestPurchasingAnalyticsModelToContractCopiesOutletData(t *testing.T) { + outletID := uuid.New() + + result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ + OrganizationID: uuid.New(), + GroupBy: "outlet_id", + OutletData: []models.PurchasingOutletData{ + { + OutletID: &outletID, + OutletName: "Outlet A", + Purchases: 500, + RawMaterialPurchases: 350, + ExpensePurchases: 150, + PurchaseOrders: 4, + RawMaterialPurchaseOrders: 3, + ExpenseCount: 2, + Quantity: 10, + Ingredients: 5, + Vendors: 2, + }, + }, + }) + + require.NotNil(t, result) + require.Equal(t, "outlet_id", result.GroupBy) + require.Len(t, result.OutletData, 1) + require.Equal(t, &outletID, result.OutletData[0].OutletID) + require.Equal(t, "Outlet A", result.OutletData[0].OutletName) + require.Equal(t, float64(500), result.OutletData[0].Purchases) + require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases) + require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases) + require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders) +} + func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) { orgID := uuid.New() outletID := uuid.New().String() -- 2.47.2