Dev #27

Merged
aefril merged 4 commits from dev into main 2026-08-11 17:46:44 +02:00
19 changed files with 532 additions and 17 deletions
Showing only changes of commit e6078e3c0b - Show all commits
+7
View File
@@ -9,4 +9,11 @@ const (
// PurchaseTeamCentralName is what Pusat is called in the picker. Pusat has no // 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. // row of its own, so the name lives here rather than in the database.
PurchaseTeamCentralName = "Pusat" 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"
) )
+20
View File
@@ -88,6 +88,9 @@ type SalesAnalyticsData struct {
type PurchasingAnalyticsRequest struct { type PurchasingAnalyticsRequest struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"` OutletID *string `form:"outlet_id,omitempty"`
// 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"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" 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"`
@@ -97,6 +100,7 @@ type PurchasingAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"` OutletName *string `json:"outlet_name,omitempty"`
Team string `json:"team,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@@ -104,6 +108,21 @@ type PurchasingAnalyticsResponse struct {
Data []PurchasingAnalyticsData `json:"data"` Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_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 { type PurchasingSummary struct {
@@ -117,6 +136,7 @@ type PurchasingSummary struct {
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"` TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"` TotalVendors int64 `json:"total_vendors"`
TotalTeams int64 `json:"total_teams"`
} }
type PurchasingAnalyticsData struct { type PurchasingAnalyticsData struct {
@@ -119,6 +119,11 @@ type ListPurchaseOrdersRequest struct {
Search string `json:"search,omitempty"` Search string `json:"search,omitempty"`
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
VendorID *uuid.UUID `json:"vendor_id,omitempty"` 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"` TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
StartDate *time.Time `json:"start_date,omitempty"` StartDate *time.Time `json:"start_date,omitempty"`
+25
View File
@@ -27,6 +27,14 @@ type SalesAnalytics struct {
NetSales float64 `json:"net_sales"` 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 // PurchasingAnalytics represents purchasing analytics data
type PurchasingAnalytics struct { type PurchasingAnalytics struct {
OutletName *string `json:"outlet_name,omitempty"` OutletName *string `json:"outlet_name,omitempty"`
@@ -34,6 +42,22 @@ type PurchasingAnalytics struct {
Data []PurchasingAnalyticsData `json:"data"` Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_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 { type PurchasingSummary struct {
@@ -47,6 +71,7 @@ type PurchasingSummary struct {
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"` TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"` TotalVendors int64 `json:"total_vendors"`
TotalTeams int64 `json:"total_teams"`
} }
type PurchasingAnalyticsData struct { type PurchasingAnalyticsData struct {
@@ -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 != "" { if teamScope := c.Query("team_scope"); teamScope != "" {
req.TeamScope = teamScope req.TeamScope = teamScope
} }
+45
View File
@@ -1,8 +1,12 @@
package models package models
import ( import (
"fmt"
"time" "time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -93,16 +97,42 @@ type SalesAnalyticsData struct {
type PurchasingAnalyticsRequest struct { type PurchasingAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"` OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"` OutletID *uuid.UUID `validate:"omitempty"`
// 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"` DateFrom time.Time `validate:"required"`
DateTo 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"`
} }
// 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 // PurchasingAnalyticsResponse represents the response for purchasing analytics
type PurchasingAnalyticsResponse struct { type PurchasingAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"` OutletName *string `json:"outlet_name,omitempty"`
Team string `json:"team,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@@ -110,6 +140,20 @@ type PurchasingAnalyticsResponse struct {
Data []PurchasingAnalyticsData `json:"data"` Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_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 // PurchasingSummary represents the summary of purchasing analytics
@@ -124,6 +168,7 @@ type PurchasingSummary struct {
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"` TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"` TotalVendors int64 `json:"total_vendors"`
TotalTeams int64 `json:"total_teams"`
} }
// PurchasingAnalyticsData represents purchasing analytics by time period // PurchasingAnalyticsData represents purchasing analytics by time period
+1
View File
@@ -152,6 +152,7 @@ type ListPurchaseOrdersRequest struct {
Search string `json:"search,omitempty"` Search string `json:"search,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
VendorID *uuid.UUID `json:"vendor_id,omitempty"` VendorID *uuid.UUID `json:"vendor_id,omitempty"`
Team string `json:"team,omitempty"`
TeamScope string `json:"team_scope,omitempty"` TeamScope string `json:"team_scope,omitempty"`
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
StartDate *time.Time `json:"start_date,omitempty"` StartDate *time.Time `json:"start_date,omitempty"`
+24 -1
View File
@@ -200,7 +200,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
req.GroupBy = "day" 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 { if err != nil {
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err) 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{ return &models.PurchasingAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: result.OutletName, OutletName: result.OutletName,
Team: req.Team,
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@@ -263,10 +284,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients, TotalIngredients: result.Summary.TotalIngredients,
TotalVendors: result.Summary.TotalVendors, TotalVendors: result.Summary.TotalVendors,
TotalTeams: result.Summary.TotalTeams,
}, },
Data: data, Data: data,
IngredientData: ingredientData, IngredientData: ingredientData,
VendorData: vendorData, VendorData: vendorData,
TeamData: teamData,
}, nil }, nil
} }
+108 -1
View File
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"time" "time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
@@ -14,6 +15,7 @@ import (
type analyticsRepositoryStub struct { type analyticsRepositoryStub struct {
purchasingResult *entities.PurchasingAnalytics purchasingResult *entities.PurchasingAnalytics
purchasingTeam *entities.PurchaseTeamFilter
budgetCutOffWeeks []*entities.BudgetCutOffWeek budgetCutOffWeeks []*entities.BudgetCutOffWeek
profitLossResult *entities.ProfitLossAnalytics profitLossResult *entities.ProfitLossAnalytics
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
@@ -32,7 +34,8 @@ func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uu
return nil, nil 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 return s.purchasingResult, nil
} }
@@ -158,6 +161,110 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases) 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) { func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
productID := uuid.New() productID := uuid.New()
categoryID := uuid.New() categoryID := uuid.New()
+115 -4
View File
@@ -6,6 +6,7 @@ import (
"sort" "sort"
"time" "time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"github.com/google/uuid" "github.com/google/uuid"
@@ -15,7 +16,7 @@ import (
type AnalyticsRepository interface { type AnalyticsRepository interface {
GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) 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) 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) 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) 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) 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 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 var outletName *string
if outletID != nil { if outletID != nil {
@@ -179,10 +180,10 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
outletName = &outlet.Name 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 var summary entities.PurchasingSummary
summaryQuery := r.db.WithContext(ctx). summaryQuery := r.db.WithContext(ctx).
Table("purchase_orders po"). Table("purchase_orders po").
@@ -210,6 +211,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Where("po.status != ?", "cancelled"). Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
summaryQuery = r.applyPurchaseOrderTeamFilter(summaryQuery, team)
if err := summaryQuery.Scan(&summary).Error; err != nil { if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err return nil, err
@@ -253,6 +255,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Group(dateFormat). Group(dateFormat).
Order(dateFormat) Order(dateFormat)
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID) dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
dataQuery = r.applyPurchaseOrderTeamFilter(dataQuery, team)
if err := dataQuery.Scan(&data).Error; err != nil { if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err return nil, err
@@ -283,6 +286,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Group("i.id, i.name"). Group("i.id, i.name").
Order("total_cost DESC") Order("total_cost DESC")
ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID) ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID)
ingredientQuery = r.applyPurchaseOrderTeamFilter(ingredientQuery, team)
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
return nil, err return nil, err
@@ -310,20 +314,105 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Group("v.id, COALESCE(v.name, 'No Vendor')"). Group("v.id, COALESCE(v.name, 'No Vendor')").
Order("total_cost DESC") Order("total_cost DESC")
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID) vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
vendorQuery = r.applyPurchaseOrderTeamFilter(vendorQuery, team)
if err := vendorQuery.Scan(&vendorData).Error; err != nil { if err := vendorQuery.Scan(&vendorData).Error; err != nil {
return nil, err 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{ return &entities.PurchasingAnalytics{
OutletName: outletName, OutletName: outletName,
Summary: summary, Summary: summary,
Data: data, Data: data,
IngredientData: ingredientData, IngredientData: ingredientData,
VendorData: vendorData, VendorData: vendorData,
TeamData: teamData,
}, nil }, 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 { func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB {
if outletID == nil { if outletID == nil {
return query return query
@@ -331,6 +420,28 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm
return query.Where("po.outlet_id = ?", *outletID) 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) { 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 var results []*entities.ProductAnalytics
@@ -101,6 +101,10 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
if teamCategoryID, ok := value.(uuid.UUID); ok { if teamCategoryID, ok := value.(uuid.UUID); ok {
query = query.Where("team_category_id = ?", teamCategoryID) 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": case "start_date":
if startDate, ok := value.(time.Time); ok { if startDate, ok := value.(time.Time); ok {
query = query.Where("transaction_date >= ?", startDate) 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 { if teamCategoryID, ok := value.(uuid.UUID); ok {
query = query.Where("team_category_id = ?", teamCategoryID) 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": case "start_date":
if startDate, ok := value.(time.Time); ok { if startDate, ok := value.(time.Time); ok {
query = query.Where("transaction_date >= ?", startDate) query = query.Where("transaction_date >= ?", startDate)
+4
View File
@@ -238,6 +238,10 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu
} }
} }
if _, err := models.ParsePurchaseTeamFilter(req.Team); err != nil {
return err
}
return nil return nil
} }
@@ -113,6 +113,16 @@ func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
}, },
wantErr: "invalid group_by value: quarter", 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 { for _, tt := range tests {
@@ -120,6 +120,20 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
if modelReq.TeamCategoryID != nil { if modelReq.TeamCategoryID != nil {
filters["team_category_id"] = *modelReq.TeamCategoryID 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 { if modelReq.StartDate != nil {
filters["start_date"] = *modelReq.StartDate filters["start_date"] = *modelReq.StartDate
} }
@@ -156,6 +156,7 @@ func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest
return &models.PurchasingAnalyticsRequest{ return &models.PurchasingAnalyticsRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID), OutletID: parseOutletID(req.OutletID),
Team: req.Team,
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
GroupBy: req.GroupBy, 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{ return &contract.PurchasingAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName, OutletName: resp.OutletName,
Team: resp.Team,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
@@ -226,10 +243,12 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
TotalIngredients: resp.Summary.TotalIngredients, TotalIngredients: resp.Summary.TotalIngredients,
TotalVendors: resp.Summary.TotalVendors, TotalVendors: resp.Summary.TotalVendors,
TotalTeams: resp.Summary.TotalTeams,
}, },
Data: data, Data: data,
IngredientData: ingredientData, IngredientData: ingredientData,
VendorData: vendorData, VendorData: vendorData,
TeamData: teamData,
} }
} }
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"time" "time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract" "apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
@@ -95,6 +96,49 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
require.Equal(t, float64(175), result.Data[0].ExpensePurchases) 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) { func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
OrganizationID: uuid.New(), OrganizationID: uuid.New(),
@@ -110,6 +110,7 @@ func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) *
Search: req.Search, Search: req.Search,
Status: req.Status, Status: req.Status,
VendorID: req.VendorID, VendorID: req.VendorID,
Team: req.Team,
TeamScope: req.TeamScope, TeamScope: req.TeamScope,
TeamCategoryID: req.TeamCategoryID, TeamCategoryID: req.TeamCategoryID,
StartDate: req.StartDate, StartDate: req.StartDate,
@@ -193,6 +193,21 @@ func validatePurchaseTeamSelection(scope *string, categoryID *uuid.UUID, allowCl
return nil, "" 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) { func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) {
if req == nil { if req == nil {
return errors.New("request body is required"), constants.MissingFieldErrorCode 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 != "" { if req.TeamScope != "" {
validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral} validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral}
if !contains(validScopes, req.TeamScope) { if !contains(validScopes, req.TeamScope) {
@@ -195,6 +195,48 @@ func TestPurchaseOrderValidatorUpdateRejectsClearingTeamWithCategory(t *testing.
require.Equal(t, constants.MalformedFieldErrorCode, code) 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) { func TestPurchaseOrderValidatorListRejectsCentralScopeWithCategory(t *testing.T) {
validator := NewPurchaseOrderValidator() validator := NewPurchaseOrderValidator()
categoryID := uuid.New() categoryID := uuid.New()