feat: add limit owner
This commit is contained in:
@@ -413,9 +413,22 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerParentCategory(ctx contex
|
||||
// Transform data
|
||||
var resultData []models.ProductAnalyticsPerParentCategoryData
|
||||
for _, data := range analyticsData {
|
||||
var topProduct *models.ParentCategoryTopProduct
|
||||
if data.TopProduct != nil {
|
||||
topProduct = &models.ParentCategoryTopProduct{
|
||||
ProductID: data.TopProduct.ProductID,
|
||||
ProductName: data.TopProduct.ProductName,
|
||||
QuantitySold: data.TopProduct.QuantitySold,
|
||||
Revenue: data.TopProduct.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
resultData = append(resultData, models.ProductAnalyticsPerParentCategoryData{
|
||||
TopProduct: topProduct,
|
||||
ParentCategoryID: data.ParentCategoryID,
|
||||
ParentCategoryName: data.ParentCategoryName,
|
||||
OwnerFeePercent: data.OwnerFeePercent,
|
||||
SDL: data.TotalRevenue * data.OwnerFeePercent / 100,
|
||||
TotalRevenue: data.TotalRevenue,
|
||||
TotalQuantity: data.TotalQuantity,
|
||||
CategoryCount: data.CategoryCount,
|
||||
@@ -500,6 +513,9 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
summary := models.ParentCategoryAnalyticsDetailSummary{}
|
||||
if detail.Summary != nil {
|
||||
summary = models.ParentCategoryAnalyticsDetailSummary{
|
||||
TopProduct: topProductByRevenue(detail.Products),
|
||||
OwnerFeePercent: detail.Summary.OwnerFeePercent,
|
||||
SDL: detail.Summary.TotalRevenue * detail.Summary.OwnerFeePercent / 100,
|
||||
TotalRevenue: detail.Summary.TotalRevenue,
|
||||
TotalQuantity: detail.Summary.TotalQuantity,
|
||||
CategoryCount: detail.Summary.CategoryCount,
|
||||
@@ -515,6 +531,10 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The block covers a single parent category, so it reports that category's own fee
|
||||
if detail.Summary != nil {
|
||||
budget.Percentages.Owner = detail.Summary.OwnerFeePercent
|
||||
}
|
||||
|
||||
return &models.ParentCategoryAnalyticsDetailResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
@@ -530,6 +550,30 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
}, nil
|
||||
}
|
||||
|
||||
// topProductByRevenue picks the best-selling product of a parent category. A product
|
||||
// can span several rows (one per outlet price), so the rows are added up per product
|
||||
// first. Ties keep the product that was seen first.
|
||||
func topProductByRevenue(products []*entities.ProductAnalytics) *models.ParentCategoryTopProduct {
|
||||
totals := make(map[uuid.UUID]*models.ParentCategoryTopProduct)
|
||||
for _, product := range products {
|
||||
total, ok := totals[product.ProductID]
|
||||
if !ok {
|
||||
total = &models.ParentCategoryTopProduct{ProductID: product.ProductID, ProductName: product.ProductName}
|
||||
totals[product.ProductID] = total
|
||||
}
|
||||
total.QuantitySold += product.QuantitySold
|
||||
total.Revenue += product.Revenue
|
||||
}
|
||||
|
||||
var top *models.ParentCategoryTopProduct
|
||||
for _, product := range products {
|
||||
if total := totals[product.ProductID]; top == nil || total.Revenue > top.Revenue {
|
||||
top = total
|
||||
}
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
// startOfWeek returns the Monday 00:00 of the week containing t, in t's own location.
|
||||
func startOfWeek(t time.Time) time.Time {
|
||||
daysSinceMonday := (int(t.Weekday()) + 6) % 7
|
||||
@@ -542,15 +586,16 @@ func endOfWeek(t time.Time) time.Time {
|
||||
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
|
||||
}
|
||||
|
||||
// newBudgetPeriod splits a period's revenue into the spending limits.
|
||||
func newBudgetPeriod(start, end time.Time, revenue float64, orderCount int64) models.BudgetPeriod {
|
||||
// newBudgetPeriod splits a period's revenue into the spending limits. The owner limit
|
||||
// is passed in because a parent category can carry its own owner fee percent.
|
||||
func newBudgetPeriod(start, end time.Time, revenue, sdl float64, orderCount int64) models.BudgetPeriod {
|
||||
return models.BudgetPeriod{
|
||||
PeriodStart: start,
|
||||
PeriodEnd: end,
|
||||
Revenue: revenue,
|
||||
OrderCount: orderCount,
|
||||
LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100,
|
||||
LimitOwner: revenue * constants.BudgetLimitOwnerPercent / 100,
|
||||
SDL: sdl,
|
||||
LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100,
|
||||
}
|
||||
}
|
||||
@@ -588,22 +633,24 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
||||
|
||||
var (
|
||||
totalRevenue float64
|
||||
totalSDL float64
|
||||
totalOrders int64
|
||||
monthOrder []string
|
||||
monthAccumulator = map[string]*models.BudgetMonthPeriod{}
|
||||
)
|
||||
|
||||
for week := cutOffFrom; !week.After(cutOffTo); week = week.AddDate(0, 0, 7) {
|
||||
var revenue float64
|
||||
var revenue, sdl float64
|
||||
var orderCount int64
|
||||
if row, ok := rowsByWeek[week.Format("2006-01-02")]; ok {
|
||||
revenue, orderCount = row.Revenue, row.OrderCount
|
||||
revenue, sdl, orderCount = row.Revenue, row.SDL, row.OrderCount
|
||||
}
|
||||
|
||||
period := newBudgetPeriod(week, endOfWeek(week), revenue, orderCount)
|
||||
period := newBudgetPeriod(week, endOfWeek(week), revenue, sdl, orderCount)
|
||||
budget.Weekly = append(budget.Weekly, period)
|
||||
|
||||
totalRevenue += revenue
|
||||
totalSDL += sdl
|
||||
totalOrders += orderCount
|
||||
|
||||
// A week belongs to the month of its Monday, so every week is counted once
|
||||
@@ -618,6 +665,7 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
||||
month.WeekCount++
|
||||
month.PeriodEnd = period.PeriodEnd
|
||||
month.Revenue += revenue
|
||||
month.SDL += sdl
|
||||
month.OrderCount += orderCount
|
||||
}
|
||||
|
||||
@@ -626,11 +674,11 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
||||
budget.Monthly = append(budget.Monthly, models.BudgetMonthPeriod{
|
||||
Month: month.Month,
|
||||
WeekCount: month.WeekCount,
|
||||
BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.OrderCount),
|
||||
BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.SDL, month.OrderCount),
|
||||
})
|
||||
}
|
||||
|
||||
budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalOrders)
|
||||
budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalSDL, totalOrders)
|
||||
|
||||
return budget, nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type analyticsRepositoryStub struct {
|
||||
purchasingResult *entities.PurchasingAnalytics
|
||||
purchasingTeam *entities.PurchaseTeamFilter
|
||||
budgetCutOffWeeks []*entities.BudgetCutOffWeek
|
||||
parentCategories []*entities.ProductAnalyticsPerParentCategory
|
||||
profitLossResult *entities.ProfitLossAnalytics
|
||||
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
||||
bankBalances []entities.ExclusiveSummaryBankBalance
|
||||
@@ -49,8 +50,8 @@ func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, u
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
|
||||
return nil, nil
|
||||
func (s analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
|
||||
return s.parentCategories, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) {
|
||||
@@ -604,3 +605,60 @@ func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) {
|
||||
assert.Zero(t, byType["point"].Percentage)
|
||||
assert.Equal(t, int64(30000), byType["point"].PointsUsed)
|
||||
}
|
||||
|
||||
// A parent category with its own owner fee percent moves the owner limit away from the
|
||||
// default share, while purchase and team stay on the default split of revenue.
|
||||
func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
|
||||
monday := time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC)
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
parentCategories: []*entities.ProductAnalyticsPerParentCategory{
|
||||
{ParentCategoryName: "Food", OwnerFeePercent: constants.BudgetLimitOwnerPercent, TotalRevenue: 1000},
|
||||
{ParentCategoryName: "Drink", OwnerFeePercent: 35, TotalRevenue: 2000},
|
||||
},
|
||||
budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||
// 1000 at the default 20% plus 2000 at 35%
|
||||
{WeekStart: monday, Revenue: 3000, OrderCount: 4, SDL: 900},
|
||||
},
|
||||
}, expenseRepositoryStub{})
|
||||
|
||||
result, err := processor.GetProductAnalyticsPerParentCategory(context.Background(), &models.ProductAnalyticsPerParentCategoryRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
DateFrom: monday,
|
||||
DateTo: monday.AddDate(0, 0, 6),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Data, 2)
|
||||
require.Equal(t, float64(20), result.Data[0].OwnerFeePercent)
|
||||
require.Equal(t, float64(200), result.Data[0].SDL)
|
||||
require.Equal(t, float64(35), result.Data[1].OwnerFeePercent)
|
||||
require.Equal(t, float64(700), result.Data[1].SDL)
|
||||
|
||||
require.Len(t, result.Budget.Weekly, 1)
|
||||
require.Equal(t, float64(900), result.Budget.Weekly[0].SDL)
|
||||
require.Equal(t, float64(1800), result.Budget.Weekly[0].LimitPurchase)
|
||||
require.Equal(t, float64(600), result.Budget.Weekly[0].LimitTeam)
|
||||
require.Len(t, result.Budget.Monthly, 1)
|
||||
require.Equal(t, float64(900), result.Budget.Monthly[0].SDL)
|
||||
require.Equal(t, float64(900), result.Budget.Total.SDL)
|
||||
}
|
||||
|
||||
// A product sold at several outlet prices comes back as several rows, which must be
|
||||
// added up before the best seller is picked.
|
||||
func TestTopProductByRevenueAddsUpRowsOfTheSameProduct(t *testing.T) {
|
||||
split, single := uuid.New(), uuid.New()
|
||||
|
||||
top := topProductByRevenue([]*entities.ProductAnalytics{
|
||||
{ProductID: single, ProductName: "Es Teh", QuantitySold: 5, Revenue: 500},
|
||||
{ProductID: split, ProductName: "Nasi Goreng", QuantitySold: 2, Revenue: 300},
|
||||
{ProductID: split, ProductName: "Nasi Goreng", QuantitySold: 2, Revenue: 300},
|
||||
})
|
||||
|
||||
require.NotNil(t, top)
|
||||
require.Equal(t, split, top.ProductID)
|
||||
require.Equal(t, "Nasi Goreng", top.ProductName)
|
||||
require.Equal(t, int64(4), top.QuantitySold)
|
||||
require.Equal(t, float64(600), top.Revenue)
|
||||
|
||||
require.Nil(t, topProductByRevenue(nil))
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
|
||||
weekStart := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||
|
||||
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||
{WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120},
|
||||
{WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120, SDL: 2_500_000},
|
||||
}}
|
||||
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||
|
||||
@@ -93,16 +93,18 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, budget.Weekly, 1)
|
||||
|
||||
// 60 / 20 / 20 of the week's revenue
|
||||
// 60 / 20 of the week's revenue; the owner limit is whatever the repository
|
||||
// summed from each parent category's own fee percent
|
||||
week := budget.Weekly[0]
|
||||
require.Equal(t, float64(6_000_000), week.LimitPurchase)
|
||||
require.Equal(t, float64(2_000_000), week.LimitOwner)
|
||||
require.Equal(t, float64(2_500_000), week.SDL)
|
||||
require.Equal(t, float64(2_000_000), week.LimitTeam)
|
||||
require.Equal(t, int64(120), week.OrderCount)
|
||||
|
||||
// Totals mirror the single week
|
||||
require.Equal(t, week.Revenue, budget.Total.Revenue)
|
||||
require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase)
|
||||
require.Equal(t, week.SDL, budget.Total.SDL)
|
||||
}
|
||||
|
||||
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user