Add grouping by outlet_id #22

Open
ryan wants to merge 1 commits from feature/outlet-grouping into main
10 changed files with 263 additions and 51 deletions

View File

@ -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"`

View File

@ -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 {

View File

@ -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"`
}

View File

@ -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

View File

@ -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()

View File

@ -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) {

View File

@ -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)

View File

@ -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)

View File

@ -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,
}

View File

@ -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()