diff --git a/internal/constants/purchase_team.go b/internal/constants/purchase_team.go index c3cc147..e066381 100644 --- a/internal/constants/purchase_team.go +++ b/internal/constants/purchase_team.go @@ -9,4 +9,11 @@ const ( // PurchaseTeamCentralName is what Pusat is called in the picker. Pusat has no // row of its own, so the name lives here rather than in the database. PurchaseTeamCentralName = "Pusat" + + // PurchaseTeamNone is the value the list filter takes to ask for purchases + // that have not been charged to any team yet. + PurchaseTeamNone = "none" + + // PurchaseTeamNoneName labels those purchases in the reports. + PurchaseTeamNoneName = "Tanpa Team" ) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index b49fd58..918be35 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -88,15 +88,19 @@ type SalesAnalyticsData struct { 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"` + // Team narrows the report to one team: a parent category id, "central" for + // Pusat, or "none" for purchases charged to no team. Empty covers all teams. + Team string `form:"team,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"` + Team string `json:"team,omitempty"` DateFrom time.Time `json:"date_from"` DateTo time.Time `json:"date_to"` GroupBy string `json:"group_by"` @@ -104,6 +108,21 @@ type PurchasingAnalyticsResponse struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData is one team's share of the purchases. Scope and CategoryID +// are exactly what the team filter takes, so a row doubles as a drill-down link. +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } type PurchasingSummary struct { @@ -117,6 +136,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } type PurchasingAnalyticsData struct { diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 79b14b2..26bbd52 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -114,11 +114,16 @@ type PurchaseOrderAttachmentResponse struct { } type ListPurchaseOrdersRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search,omitempty"` - Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` - VendorID *uuid.UUID `json:"vendor_id,omitempty"` + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search,omitempty"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` + VendorID *uuid.UUID `json:"vendor_id,omitempty"` + // Team is the single-value form of the two filters below, so the team picker + // can send back what it was given: a parent category id, "central" for Pusat, + // or "none" for purchases with no team yet. It replaces them rather than + // narrowing alongside them. + Team string `json:"team,omitempty"` TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` StartDate *time.Time `json:"start_date,omitempty"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 4ea0933..fc38018 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -27,6 +27,14 @@ type SalesAnalytics struct { NetSales float64 `json:"net_sales"` } +// PurchaseTeamFilter narrows purchasing figures to a single team: a parent +// category, Pusat, or the purchases that carry no team at all. A nil filter +// leaves the figures spanning every team. +type PurchaseTeamFilter struct { + Scope string + CategoryID *uuid.UUID +} + // PurchasingAnalytics represents purchasing analytics data type PurchasingAnalytics struct { OutletName *string `json:"outlet_name,omitempty"` @@ -34,6 +42,22 @@ type PurchasingAnalytics struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData is one team's share of the purchases: a parent category, +// Pusat, or the purchases charged to no team at all. Scope and CategoryID are +// what the team filter takes back, so a row can be clicked straight through. +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } type PurchasingSummary struct { @@ -47,6 +71,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } type PurchasingAnalyticsData struct { diff --git a/internal/handler/purchase_order_handler.go b/internal/handler/purchase_order_handler.go index b210fd0..f1d1012 100644 --- a/internal/handler/purchase_order_handler.go +++ b/internal/handler/purchase_order_handler.go @@ -176,6 +176,10 @@ func (h *PurchaseOrderHandler) ListPurchaseOrders(c *gin.Context) { } } + if team := c.Query("team"); team != "" { + req.Team = team + } + if teamScope := c.Query("team_scope"); teamScope != "" { req.TeamScope = teamScope } diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 60b511d..d3aaa07 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -1,8 +1,12 @@ package models import ( + "fmt" "time" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "github.com/google/uuid" ) @@ -93,9 +97,34 @@ type SalesAnalyticsData struct { 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"` + // Team is the raw value the team picker sends: a parent category id, + // "central" for Pusat, "none" for purchases with no team, or empty for all. + Team string + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` + GroupBy string `validate:"omitempty,oneof=day hour week month"` +} + +// ParsePurchaseTeamFilter turns the team value the picker sends into the scope and +// category the purchasing queries filter on. An empty value spans every team; an +// unknown one is an error rather than a report that quietly ignores the filter. +func ParsePurchaseTeamFilter(team string) (*entities.PurchaseTeamFilter, error) { + switch team { + case "": + return nil, nil + case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone: + return &entities.PurchaseTeamFilter{Scope: team}, nil + } + + categoryID, err := uuid.Parse(team) + if err != nil || categoryID == uuid.Nil { + return nil, fmt.Errorf("team must be one of: central, none, or a category id") + } + + return &entities.PurchaseTeamFilter{ + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + }, nil } // PurchasingAnalyticsResponse represents the response for purchasing analytics @@ -103,6 +132,7 @@ type PurchasingAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletName *string `json:"outlet_name,omitempty"` + Team string `json:"team,omitempty"` DateFrom time.Time `json:"date_from"` DateTo time.Time `json:"date_to"` GroupBy string `json:"group_by"` @@ -110,6 +140,20 @@ type PurchasingAnalyticsResponse struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData represents purchasing analytics for a single team +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } // PurchasingSummary represents the summary of purchasing analytics @@ -124,6 +168,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } // PurchasingAnalyticsData represents purchasing analytics by time period diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index e5e1287..6ac202e 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -152,6 +152,7 @@ type ListPurchaseOrdersRequest struct { Search string `json:"search,omitempty"` Status string `json:"status,omitempty"` VendorID *uuid.UUID `json:"vendor_id,omitempty"` + Team string `json:"team,omitempty"` TeamScope string `json:"team_scope,omitempty"` TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` StartDate *time.Time `json:"start_date,omitempty"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 2560124..37ad289 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -200,7 +200,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req req.GroupBy = "day" } - result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) + teamFilter, err := models.ParsePurchaseTeamFilter(req.Team) + if err != nil { + return nil, err + } + + result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, teamFilter, req.DateFrom, req.DateTo, req.GroupBy) if err != nil { return nil, fmt.Errorf("failed to get purchasing analytics: %w", err) } @@ -245,10 +250,26 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req } } + teamData := make([]models.PurchasingTeamData, len(result.TeamData)) + for i, item := range result.TeamData { + teamData[i] = models.PurchasingTeamData{ + Scope: item.Scope, + CategoryID: item.CategoryID, + Name: item.Name, + TotalPurchases: item.TotalPurchases, + RawMaterialPurchases: item.RawMaterialPurchases, + ExpensePurchases: item.ExpensePurchases, + PurchaseOrderCount: item.PurchaseOrderCount, + Quantity: item.Quantity, + Percentage: item.Percentage, + } + } + return &models.PurchasingAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, OutletName: result.OutletName, + Team: req.Team, DateFrom: req.DateFrom, DateTo: req.DateTo, GroupBy: req.GroupBy, @@ -263,10 +284,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, TotalIngredients: result.Summary.TotalIngredients, TotalVendors: result.Summary.TotalVendors, + TotalTeams: result.Summary.TotalTeams, }, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, }, nil } diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 6cb4722..7ae2d0e 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" @@ -14,6 +15,7 @@ import ( type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics + purchasingTeam *entities.PurchaseTeamFilter budgetCutOffWeeks []*entities.BudgetCutOffWeek profitLossResult *entities.ProfitLossAnalytics exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics @@ -32,7 +34,8 @@ func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uu return nil, nil } -func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) { +func (s *analyticsRepositoryStub) GetPurchasingAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, team *entities.PurchaseTeamFilter, _, _ time.Time, _ string) (*entities.PurchasingAnalytics, error) { + s.purchasingTeam = team return s.purchasingResult, nil } @@ -158,6 +161,110 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } +func TestAnalyticsProcessorGetPurchasingAnalyticsPassesTeamFilter(t *testing.T) { + categoryID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + team string + want *entities.PurchaseTeamFilter + }{ + {name: "all teams", team: "", want: nil}, + {name: "pusat", team: constants.PurchaseTeamScopeCentral, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCentral}}, + {name: "no team", team: constants.PurchaseTeamNone, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamNone}}, + { + name: "category team", + team: categoryID.String(), + want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCategory, CategoryID: &categoryID}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}} + processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + Team: tt.team, + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.Equal(t, tt.team, result.Team) + require.Equal(t, tt.want, repo.purchasingTeam) + }) + } +} + +func TestAnalyticsProcessorGetPurchasingAnalyticsMapsTeamBreakdown(t *testing.T) { + categoryID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ + purchasingResult: &entities.PurchasingAnalytics{ + Summary: entities.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2}, + TeamData: []entities.PurchasingTeamData{ + { + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: "Kitchen", + TotalPurchases: 200, + RawMaterialPurchases: 150, + ExpensePurchases: 50, + PurchaseOrderCount: 2, + Quantity: 12, + Percentage: 66.67, + }, + { + Scope: constants.PurchaseTeamNone, + Name: constants.PurchaseTeamNoneName, + TotalPurchases: 100, + PurchaseOrderCount: 1, + Percentage: 33.33, + }, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.Equal(t, int64(2), result.Summary.TotalTeams) + require.Len(t, result.TeamData, 2) + require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope) + require.Equal(t, &categoryID, result.TeamData[0].CategoryID) + require.Equal(t, "Kitchen", result.TeamData[0].Name) + require.Equal(t, float64(200), result.TeamData[0].TotalPurchases) + require.Equal(t, float64(150), result.TeamData[0].RawMaterialPurchases) + require.Equal(t, 66.67, result.TeamData[0].Percentage) + require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope) + require.Nil(t, result.TeamData[1].CategoryID) + require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name) +} + +func TestAnalyticsProcessorGetPurchasingAnalyticsRejectsUnknownTeam(t *testing.T) { + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}} + processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + Team: "marketing", + DateFrom: now, + DateTo: now, + }) + + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "team must be one of") +} + 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 a1d6a86..9d83d16 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -6,6 +6,7 @@ import ( "sort" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "github.com/google/uuid" @@ -15,7 +16,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) + GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, 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) @@ -159,7 +160,7 @@ 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) { +func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { var outletName *string if outletID != nil { @@ -179,10 +180,10 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or outletName = &outlet.Name } } - return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, outletName, dateFrom, dateTo, groupBy) + return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, team, outletName, dateFrom, dateTo, groupBy) } -func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { +func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { var summary entities.PurchasingSummary summaryQuery := r.db.WithContext(ctx). Table("purchase_orders po"). @@ -210,6 +211,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) + summaryQuery = r.applyPurchaseOrderTeamFilter(summaryQuery, team) if err := summaryQuery.Scan(&summary).Error; err != nil { return nil, err @@ -253,6 +255,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group(dateFormat). Order(dateFormat) dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID) + dataQuery = r.applyPurchaseOrderTeamFilter(dataQuery, team) if err := dataQuery.Scan(&data).Error; err != nil { return nil, err @@ -283,6 +286,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group("i.id, i.name"). Order("total_cost DESC") ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID) + ingredientQuery = r.applyPurchaseOrderTeamFilter(ingredientQuery, team) if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { return nil, err @@ -310,20 +314,105 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group("v.id, COALESCE(v.name, 'No Vendor')"). Order("total_cost DESC") vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID) + vendorQuery = r.applyPurchaseOrderTeamFilter(vendorQuery, team) if err := vendorQuery.Scan(&vendorData).Error; err != nil { return nil, err } + teamData, err := r.getPurchaseOrderTeamBreakdown(ctx, organizationID, outletID, team, dateFrom, dateTo, summary.TotalPurchases) + if err != nil { + return nil, err + } + summary.TotalTeams = int64(len(teamData)) + return &entities.PurchasingAnalytics{ OutletName: outletName, Summary: summary, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, }, nil } +// getPurchaseOrderTeamBreakdown splits the purchases over the teams they were +// charged to. Purchases with no team are kept as their own row rather than +// dropped, so the rows still add up to the summary total. +func (r *AnalyticsRepositoryImpl) getPurchaseOrderTeamBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, totalPurchases float64) ([]entities.PurchasingTeamData, error) { + var rows []struct { + Scope *string + CategoryID *uuid.UUID + CategoryName *string + TotalPurchases float64 + RawMaterialPurchases float64 + ExpensePurchases float64 + PurchaseOrderCount int64 + Quantity float64 + } + + query := r.db.WithContext(ctx). + Table("purchase_orders po"). + Select(` + po.team_scope as scope, + po.team_category_id as category_id, + c.name as category_name, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases, + COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases, + COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases, + COUNT(DISTINCT po.id) as purchase_order_count, + COALESCE(SUM(poi.quantity), 0) as quantity + `). + 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 categories c ON po.team_category_id = c.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group("po.team_scope, po.team_category_id, c.name"). + Order("total_purchases DESC") + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + query = r.applyPurchaseOrderTeamFilter(query, team) + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + teamData := make([]entities.PurchasingTeamData, len(rows)) + for i, row := range rows { + entry := entities.PurchasingTeamData{ + CategoryID: row.CategoryID, + TotalPurchases: row.TotalPurchases, + RawMaterialPurchases: row.RawMaterialPurchases, + ExpensePurchases: row.ExpensePurchases, + PurchaseOrderCount: row.PurchaseOrderCount, + Quantity: row.Quantity, + } + + switch { + case row.Scope == nil: + entry.Scope = constants.PurchaseTeamNone + entry.Name = constants.PurchaseTeamNoneName + case *row.Scope == constants.PurchaseTeamScopeCentral: + entry.Scope = constants.PurchaseTeamScopeCentral + entry.Name = constants.PurchaseTeamCentralName + default: + entry.Scope = *row.Scope + if row.CategoryName != nil { + entry.Name = *row.CategoryName + } + } + + if totalPurchases != 0 { + entry.Percentage = row.TotalPurchases / totalPurchases * 100 + } + + teamData[i] = entry + } + + return teamData, nil +} + func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB { if outletID == nil { return query @@ -331,6 +420,28 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm return query.Where("po.outlet_id = ?", *outletID) } +// applyPurchaseOrderTeamFilter narrows a purchase order query to the team the +// report asked for. A nil filter, or a category team without a category, leaves +// the query spanning every team. +func (r *AnalyticsRepositoryImpl) applyPurchaseOrderTeamFilter(query *gorm.DB, team *entities.PurchaseTeamFilter) *gorm.DB { + if team == nil { + return query + } + + switch team.Scope { + case constants.PurchaseTeamNone: + return query.Where("po.team_scope IS NULL") + case constants.PurchaseTeamScopeCentral: + return query.Where("po.team_scope = ?", constants.PurchaseTeamScopeCentral) + case constants.PurchaseTeamScopeCategory: + if team.CategoryID != nil { + return query.Where("po.team_scope = ? AND po.team_category_id = ?", constants.PurchaseTeamScopeCategory, *team.CategoryID) + } + } + + return query +} + 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/purchase_order_repository.go b/internal/repository/purchase_order_repository.go index 3dd71a2..d88694f 100644 --- a/internal/repository/purchase_order_repository.go +++ b/internal/repository/purchase_order_repository.go @@ -101,6 +101,10 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u if teamCategoryID, ok := value.(uuid.UUID); ok { query = query.Where("team_category_id = ?", teamCategoryID) } + case "team_unassigned": + if unassigned, ok := value.(bool); ok && unassigned { + query = query.Where("team_scope IS NULL") + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) @@ -160,6 +164,10 @@ func (r *PurchaseOrderRepositoryImpl) Count(ctx context.Context, organizationID if teamCategoryID, ok := value.(uuid.UUID); ok { query = query.Where("team_category_id = ?", teamCategoryID) } + case "team_unassigned": + if unassigned, ok := value.(bool); ok && unassigned { + query = query.Where("team_scope IS NULL") + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 889b766..3ddb1a3 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -238,6 +238,10 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu } } + if _, err := models.ParsePurchaseTeamFilter(req.Team); err != nil { + return err + } + return nil } diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index e300d77..c67a864 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -113,6 +113,16 @@ func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { }, wantErr: "invalid group_by value: quarter", }, + { + name: "unknown team", + req: &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + Team: "marketing", + }, + wantErr: "team must be one of", + }, } for _, tt := range tests { diff --git a/internal/service/purchase_order_service.go b/internal/service/purchase_order_service.go index d6d4458..b10c8d8 100644 --- a/internal/service/purchase_order_service.go +++ b/internal/service/purchase_order_service.go @@ -120,6 +120,20 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx if modelReq.TeamCategoryID != nil { filters["team_category_id"] = *modelReq.TeamCategoryID } + // team spells out the same two filters in one value; the validator has already + // ruled out sending it together with them. + switch modelReq.Team { + case "": + case constants.PurchaseTeamNone: + filters["team_unassigned"] = true + case constants.PurchaseTeamScopeCentral: + filters["team_scope"] = constants.PurchaseTeamScopeCentral + default: + if teamCategoryID, err := uuid.Parse(modelReq.Team); err == nil { + filters["team_scope"] = constants.PurchaseTeamScopeCategory + filters["team_category_id"] = teamCategoryID + } + } if modelReq.StartDate != nil { filters["start_date"] = *modelReq.StartDate } diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 14d008f..d9214eb 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -156,6 +156,7 @@ func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest return &models.PurchasingAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: parseOutletID(req.OutletID), + Team: req.Team, DateFrom: dateFrom, DateTo: dateTo, GroupBy: req.GroupBy, @@ -208,10 +209,26 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse } } + teamData := make([]contract.PurchasingTeamData, len(resp.TeamData)) + for i, item := range resp.TeamData { + teamData[i] = contract.PurchasingTeamData{ + Scope: item.Scope, + CategoryID: item.CategoryID, + Name: item.Name, + TotalPurchases: item.TotalPurchases, + RawMaterialPurchases: item.RawMaterialPurchases, + ExpensePurchases: item.ExpensePurchases, + PurchaseOrderCount: item.PurchaseOrderCount, + Quantity: item.Quantity, + Percentage: item.Percentage, + } + } + return &contract.PurchasingAnalyticsResponse{ OrganizationID: resp.OrganizationID, OutletID: resp.OutletID, OutletName: resp.OutletName, + Team: resp.Team, DateFrom: resp.DateFrom, DateTo: resp.DateTo, GroupBy: resp.GroupBy, @@ -226,10 +243,12 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, TotalIngredients: resp.Summary.TotalIngredients, TotalVendors: resp.Summary.TotalVendors, + TotalTeams: resp.Summary.TotalTeams, }, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, } } diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 4d1327e..300decc 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/models" @@ -95,6 +96,49 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } +func TestPurchasingAnalyticsModelToContractCopiesTeamData(t *testing.T) { + categoryID := uuid.New() + + result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ + OrganizationID: uuid.New(), + Team: categoryID.String(), + Summary: models.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2}, + TeamData: []models.PurchasingTeamData{ + { + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: "Kitchen", + TotalPurchases: 200, + RawMaterialPurchases: 150, + ExpensePurchases: 50, + PurchaseOrderCount: 2, + Quantity: 12, + Percentage: 66.67, + }, + { + Scope: constants.PurchaseTeamNone, + Name: constants.PurchaseTeamNoneName, + TotalPurchases: 100, + PurchaseOrderCount: 1, + Percentage: 33.33, + }, + }, + }) + + require.NotNil(t, result) + require.Equal(t, categoryID.String(), result.Team) + require.Equal(t, int64(2), result.Summary.TotalTeams) + require.Len(t, result.TeamData, 2) + require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope) + require.Equal(t, &categoryID, result.TeamData[0].CategoryID) + require.Equal(t, "Kitchen", result.TeamData[0].Name) + require.Equal(t, float64(200), result.TeamData[0].TotalPurchases) + require.Equal(t, 66.67, result.TeamData[0].Percentage) + require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope) + require.Nil(t, result.TeamData[1].CategoryID) + require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name) +} + func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ OrganizationID: uuid.New(), diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index 704e7e8..962ec19 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -110,6 +110,7 @@ func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) * Search: req.Search, Status: req.Status, VendorID: req.VendorID, + Team: req.Team, TeamScope: req.TeamScope, TeamCategoryID: req.TeamCategoryID, StartDate: req.StartDate, diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index 3d16631..1de3855 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -193,6 +193,21 @@ func validatePurchaseTeamSelection(scope *string, categoryID *uuid.UUID, allowCl return nil, "" } +// validatePurchaseTeamFilter accepts the values the team picker hands back: Pusat, +// no team at all, or the id of the parent category a purchase is charged to. +func validatePurchaseTeamFilter(team string) (error, string) { + switch team { + case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone: + return nil, "" + } + + if categoryID, err := uuid.Parse(team); err != nil || categoryID == uuid.Nil { + return errors.New("team must be one of: central, none, or a category id"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) { if req == nil { return errors.New("request body is required"), constants.MissingFieldErrorCode @@ -213,6 +228,16 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } } + if req.Team != "" { + if req.TeamScope != "" || req.TeamCategoryID != nil { + return errors.New("team cannot be combined with team_scope or team_category_id"), constants.MalformedFieldErrorCode + } + + if err, code := validatePurchaseTeamFilter(req.Team); err != nil { + return err, code + } + } + if req.TeamScope != "" { validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral} if !contains(validScopes, req.TeamScope) { diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index 32a8eac..f7a7914 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -195,6 +195,48 @@ func TestPurchaseOrderValidatorUpdateRejectsClearingTeamWithCategory(t *testing. require.Equal(t, constants.MalformedFieldErrorCode, code) } +func TestPurchaseOrderValidatorListAcceptsTeamFilter(t *testing.T) { + validator := NewPurchaseOrderValidator() + + for _, team := range []string{constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone, uuid.New().String()} { + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: team, + }) + + require.NoError(t, err, team) + require.Empty(t, code, team) + } +} + +func TestPurchaseOrderValidatorListRejectsUnknownTeamFilter(t *testing.T) { + validator := NewPurchaseOrderValidator() + + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: "marketing", + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + +func TestPurchaseOrderValidatorListRejectsTeamWithScope(t *testing.T) { + validator := NewPurchaseOrderValidator() + + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: constants.PurchaseTeamNone, + TeamScope: constants.PurchaseTeamScopeCentral, + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + func TestPurchaseOrderValidatorListRejectsCentralScopeWithCategory(t *testing.T) { validator := NewPurchaseOrderValidator() categoryID := uuid.New()