Dev #27
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user