feat: add limit owner

This commit is contained in:
efrilm
2026-10-02 23:30:03 +07:00
parent b372821b23
commit fbe7e97dc7
18 changed files with 533 additions and 172 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
package constants
// Budget allocation of revenue used by the parent category cut-off report.
// The three shares are expected to add up to 100.
// The three shares are expected to add up to 100. BudgetLimitOwnerPercent is only the
// default: a parent category can override it with categories.owner_fee_percent.
const (
BudgetLimitPurchasePercent = 60.0
BudgetLimitOwnerPercent = 20.0
+15 -1
View File
@@ -268,6 +268,8 @@ type ProductAnalyticsPerParentCategoryResponse struct {
type ProductAnalyticsPerParentCategoryData struct {
ParentCategoryID uuid.UUID `json:"parent_category_id"`
ParentCategoryName string `json:"parent_category_name"`
OwnerFeePercent float64 `json:"owner_fee_percent"`
SDL float64 `json:"sdl"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
@@ -276,6 +278,15 @@ type ProductAnalyticsPerParentCategoryData struct {
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
TopProduct *ParentCategoryTopProduct `json:"top_product"`
}
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
type ParentCategoryTopProduct struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
QuantitySold int64 `json:"quantity_sold"`
Revenue float64 `json:"revenue"`
}
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
@@ -302,6 +313,8 @@ type ParentCategoryAnalyticsDetailResponse struct {
}
type ParentCategoryAnalyticsDetailSummary struct {
OwnerFeePercent float64 `json:"owner_fee_percent"`
SDL float64 `json:"sdl"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
@@ -310,6 +323,7 @@ type ParentCategoryAnalyticsDetailSummary struct {
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
TopProduct *ParentCategoryTopProduct `json:"top_product"`
}
type ParentCategoryAnalyticsDetailData struct {
@@ -366,7 +380,7 @@ type BudgetPeriod struct {
Revenue float64 `json:"revenue"`
OrderCount int64 `json:"order_count"`
LimitPurchase float64 `json:"limit_purchase"`
LimitOwner float64 `json:"limit_owner"`
SDL float64 `json:"sdl"`
LimitTeam float64 `json:"limit_team"`
}
+51
View File
@@ -1,6 +1,8 @@
package contract
import (
"bytes"
"encoding/json"
"time"
"github.com/google/uuid"
@@ -13,6 +15,7 @@ type CreateCategoryRequest struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
@@ -23,7 +26,54 @@ type UpdateCategoryRequest struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
// Set when the field is sent as null (or "" for parent_id), which asks for the
// value to be removed. A field that is left out stays unchanged.
ClearParentID bool `json:"-"`
ClearOwnerFeePercent bool `json:"-"`
}
// UnmarshalJSON tells an explicit null apart from a field that was left out, so a
// category can be detached from its parent and an owner fee override can be removed.
func (r *UpdateCategoryRequest) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
isEmpty := func(key string, allowEmptyString bool) bool {
value, ok := raw[key]
if !ok {
return false
}
value = bytes.TrimSpace(value)
return bytes.Equal(value, []byte("null")) || (allowEmptyString && bytes.Equal(value, []byte(`""`)))
}
clearParentID := isEmpty("parent_id", true)
clearOwnerFeePercent := isEmpty("owner_fee_percent", false)
if clearParentID {
// An empty string is not a valid UUID, so keep it away from the decoder
delete(raw, "parent_id")
cleaned, err := json.Marshal(raw)
if err != nil {
return err
}
data = cleaned
}
type plain UpdateCategoryRequest
var decoded plain
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*r = UpdateCategoryRequest(decoded)
r.ClearParentID = clearParentID
r.ClearOwnerFeePercent = clearOwnerFeePercent
return nil
}
type ListCategoriesRequest struct {
@@ -48,6 +98,7 @@ type CategoryResponse struct {
Description *string `json:"description"`
BusinessType string `json:"business_type"`
Order int `json:"order"`
OwnerFeePercent *float64 `json:"owner_fee_percent"`
Metadata map[string]interface{} `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -0,0 +1,48 @@
package contract
import (
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestUpdateCategoryRequestTellsNullFromOmitted(t *testing.T) {
parentID := uuid.New()
tests := []struct {
name string
body string
wantParentID *uuid.UUID
wantClear bool
wantClearFee bool
wantFee *float64
wantNameIsSet bool
}{
{name: "omitted leaves parent unchanged", body: `{"name":"Food"}`, wantNameIsSet: true},
{name: "null detaches the parent", body: `{"parent_id":null}`, wantClear: true},
{name: "empty string detaches the parent", body: `{"name":"Food","parent_id":""}`, wantClear: true, wantNameIsSet: true},
{name: "uuid sets the parent", body: `{"parent_id":"` + parentID.String() + `"}`, wantParentID: &parentID},
{name: "null removes the owner fee override", body: `{"owner_fee_percent":null}`, wantClearFee: true},
{name: "number sets the owner fee", body: `{"owner_fee_percent":35}`, wantFee: func() *float64 { v := 35.0; return &v }()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req UpdateCategoryRequest
require.NoError(t, json.Unmarshal([]byte(tt.body), &req))
require.Equal(t, tt.wantParentID, req.ParentID)
require.Equal(t, tt.wantClear, req.ClearParentID)
require.Equal(t, tt.wantClearFee, req.ClearOwnerFeePercent)
require.Equal(t, tt.wantFee, req.OwnerFeePercent)
require.Equal(t, tt.wantNameIsSet, req.Name != nil)
})
}
}
func TestUpdateCategoryRequestRejectsInvalidParentID(t *testing.T) {
var req UpdateCategoryRequest
require.Error(t, json.Unmarshal([]byte(`{"parent_id":"not-a-uuid"}`), &req))
}
+15
View File
@@ -145,6 +145,8 @@ type ProductAnalyticsPerCategory struct {
type ProductAnalyticsPerParentCategory struct {
ParentCategoryID uuid.UUID `json:"parent_category_id"`
ParentCategoryName string `json:"parent_category_name"`
// OwnerFeePercent is the owner share of this group's revenue, already resolved to the default
OwnerFeePercent float64 `json:"owner_fee_percent"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
@@ -153,6 +155,17 @@ type ProductAnalyticsPerParentCategory struct {
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
// TopProduct is filled by a separate query, so it is not a scanned column
TopProduct *ParentCategoryTopProduct `gorm:"-" json:"top_product"`
}
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
type ParentCategoryTopProduct struct {
ParentCategoryID uuid.UUID `json:"parent_category_id"`
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
QuantitySold int64 `json:"quantity_sold"`
Revenue float64 `json:"revenue"`
}
// ParentCategoryAnalyticsDetail is the drill-down for a single parent category:
@@ -171,6 +184,8 @@ type BudgetCutOffWeek struct {
WeekStart time.Time `json:"week_start"`
Revenue float64 `json:"revenue"`
OrderCount int64 `json:"order_count"`
// SDL is summed per parent category, each at its own owner fee percent
SDL float64 `json:"sdl"`
}
// DashboardOverview represents dashboard overview data
+2
View File
@@ -41,6 +41,8 @@ type Category struct {
Order int `gorm:"default:0" json:"order"`
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
// OwnerFeePercent overrides the owner's share in the parent category budget report; nil uses the default
OwnerFeePercent *float64 `gorm:"type:numeric(5,2)" json:"owner_fee_percent"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
+13 -1
View File
@@ -65,6 +65,7 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
Name: req.Name,
Description: req.Description,
Order: req.Order,
OwnerFeePercent: req.OwnerFeePercent,
BusinessType: "restaurant",
Metadata: metadata,
}
@@ -103,6 +104,7 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
Description: entity.Description,
ImageURL: imageURL,
Order: entity.Order,
OwnerFeePercent: entity.OwnerFeePercent,
IsActive: true,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
@@ -134,11 +136,21 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
entity.Order = *req.Order
}
if req.ClearOwnerFeePercent {
entity.OwnerFeePercent = nil
} else if req.OwnerFeePercent != nil {
entity.OwnerFeePercent = req.OwnerFeePercent
}
if req.OutletID != nil {
entity.OutletID = req.OutletID
}
if req.ParentID != nil {
if req.ClearParentID {
// Drop the preloaded parent too, so the response stops reporting it
entity.ParentID = nil
entity.Parent = nil
} else if req.ParentID != nil {
entity.ParentID = req.ParentID
}
}
+15 -1
View File
@@ -308,6 +308,8 @@ type ProductAnalyticsPerParentCategoryResponse struct {
type ProductAnalyticsPerParentCategoryData struct {
ParentCategoryID uuid.UUID `json:"parent_category_id"`
ParentCategoryName string `json:"parent_category_name"`
OwnerFeePercent float64 `json:"owner_fee_percent"`
SDL float64 `json:"sdl"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
@@ -316,6 +318,15 @@ type ProductAnalyticsPerParentCategoryData struct {
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
TopProduct *ParentCategoryTopProduct `json:"top_product"`
}
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
type ParentCategoryTopProduct struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
QuantitySold int64 `json:"quantity_sold"`
Revenue float64 `json:"revenue"`
}
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
@@ -342,6 +353,8 @@ type ParentCategoryAnalyticsDetailResponse struct {
}
type ParentCategoryAnalyticsDetailSummary struct {
OwnerFeePercent float64 `json:"owner_fee_percent"`
SDL float64 `json:"sdl"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
@@ -350,6 +363,7 @@ type ParentCategoryAnalyticsDetailSummary struct {
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
TopProduct *ParentCategoryTopProduct `json:"top_product"`
}
type ParentCategoryAnalyticsDetailData struct {
@@ -406,7 +420,7 @@ type BudgetPeriod struct {
Revenue float64 `json:"revenue"`
OrderCount int64 `json:"order_count"`
LimitPurchase float64 `json:"limit_purchase"`
LimitOwner float64 `json:"limit_owner"`
SDL float64 `json:"sdl"`
LimitTeam float64 `json:"limit_team"`
}
+7
View File
@@ -27,6 +27,7 @@ type CreateCategoryRequest struct {
Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"`
Order int `validate:"min=0"`
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
}
type UpdateCategoryRequest struct {
@@ -36,7 +37,12 @@ type UpdateCategoryRequest struct {
OutletID *uuid.UUID
ParentID *uuid.UUID
Order *int `validate:"omitempty,min=0"`
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
IsActive *bool
// Clear flags remove the value; a nil pointer above only means "leave unchanged"
ClearParentID bool
ClearOwnerFeePercent bool
}
type CategoryResponse struct {
@@ -49,6 +55,7 @@ type CategoryResponse struct {
Description *string
ImageURL *string
Order int
OwnerFeePercent *float64
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
+56 -8
View File
@@ -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
}
+60 -2
View File
@@ -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))
}
+5 -3
View File
@@ -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) {
+56 -5
View File
@@ -600,6 +600,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
Select(`
pc.id as parent_category_id,
pc.name as parent_category_name,
COALESCE(pc.owner_fee_percent, ?) as owner_fee_percent,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
COUNT(DISTINCT c.id) as category_count,
@@ -608,7 +609,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp
`).
`, constants.BudgetLimitOwnerPercent).
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
// Categories without a parent roll up to themselves, so top-level categories still appear
@@ -640,11 +641,56 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query.
Group("pc.id, pc.name").
Group("pc.id, pc.name, pc.owner_fee_percent").
Order("pc.name ASC").
Scan(&results).Error
if err != nil || len(results) == 0 {
return results, err
}
// Best-selling product of each parent category. DISTINCT ON keeps the first row per
// parent category, which the ordering makes the one with the highest revenue.
var topProducts []*entities.ParentCategoryTopProduct
topQuery := r.db.WithContext(ctx).
Table("order_items oi").
Select(`
DISTINCT ON (pc.id)
pc.id as parent_category_id,
p.id as product_id,
p.name as product_name,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as quantity_sold,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue
`).
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
Joins("JOIN orders o ON oi.order_id = o.id").
Where("o.organization_id = ?", organizationID).
Where("o.is_void = ?", false).
Where("o.is_refund = ?", false).
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
Where("oi.status != ?", entities.OrderItemStatusCancelled).
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
topQuery = r.resolveOutletID(topQuery, outletID, "o.outlet_id")
err = topQuery.
Group("pc.id, p.id, p.name").
Order("pc.id, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) DESC, p.name ASC").
Scan(&topProducts).Error
if err != nil {
return nil, err
}
topByParent := make(map[uuid.UUID]*entities.ParentCategoryTopProduct, len(topProducts))
for _, product := range topProducts {
topByParent[product.ParentCategoryID] = product
}
for _, result := range results {
result.TopProduct = topByParent[result.ParentCategoryID]
}
return results, nil
}
// movingAverageHppSubquery builds the per-product moving-average HPP lookup shared by
@@ -693,10 +739,11 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
var parent struct {
ID uuid.UUID
Name string
OwnerFeePercent float64
}
if err := r.db.WithContext(ctx).
Table("categories").
Select("id, name").
Select("id, name, COALESCE(owner_fee_percent, ?) as owner_fee_percent", constants.BudgetLimitOwnerPercent).
Where("id = ? AND organization_id = ?", parentCategoryID, organizationID).
Scan(&parent).Error; err != nil {
return nil, err
@@ -732,6 +779,7 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
}
summary.ParentCategoryID = parent.ID
summary.ParentCategoryName = parent.Name
summary.OwnerFeePercent = parent.OwnerFeePercent
detail.Summary = summary
// Sub-category rows.
@@ -810,12 +858,15 @@ func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, org
Select(`
DATE_TRUNC('week', o.created_at) as week_start,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue,
COALESCE(SUM((CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) * COALESCE(pc.owner_fee_percent, ?) / 100.0), 0) as sdl,
COUNT(DISTINCT oi.order_id) as order_count
`).
`, constants.BudgetLimitOwnerPercent).
// products and categories are joined to keep the scope identical to the report
// the block is attached to, even when no parent category filter is applied
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
// The owner fee lives on the parent category; a category without a parent is its own group
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
Joins("JOIN orders o ON oi.order_id = o.id").
Where("o.organization_id = ?", organizationID).
Where("o.is_void = ?", false).
+20 -1
View File
@@ -405,6 +405,9 @@ func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyt
data = append(data, contract.ProductAnalyticsPerParentCategoryData{
ParentCategoryID: item.ParentCategoryID,
ParentCategoryName: item.ParentCategoryName,
OwnerFeePercent: item.OwnerFeePercent,
TopProduct: parentCategoryTopProductModelToContract(item.TopProduct),
SDL: item.SDL,
TotalRevenue: item.TotalRevenue,
TotalQuantity: item.TotalQuantity,
CategoryCount: item.CategoryCount,
@@ -427,6 +430,19 @@ func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyt
}
}
// parentCategoryTopProductModelToContract converts the top product of a parent category to contract
func parentCategoryTopProductModelToContract(product *models.ParentCategoryTopProduct) *contract.ParentCategoryTopProduct {
if product == nil {
return nil
}
return &contract.ParentCategoryTopProduct{
ProductID: product.ProductID,
ProductName: product.ProductName,
QuantitySold: product.QuantitySold,
Revenue: product.Revenue,
}
}
// budgetPeriodModelToContract converts one budget period to contract
func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod {
return contract.BudgetPeriod{
@@ -435,7 +451,7 @@ func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeri
Revenue: period.Revenue,
OrderCount: period.OrderCount,
LimitPurchase: period.LimitPurchase,
LimitOwner: period.LimitOwner,
SDL: period.SDL,
LimitTeam: period.LimitTeam,
}
}
@@ -548,6 +564,9 @@ func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAna
ParentCategoryID: resp.ParentCategoryID,
ParentCategoryName: resp.ParentCategoryName,
Summary: contract.ParentCategoryAnalyticsDetailSummary{
OwnerFeePercent: resp.Summary.OwnerFeePercent,
TopProduct: parentCategoryTopProductModelToContract(resp.Summary.TopProduct),
SDL: resp.Summary.SDL,
TotalRevenue: resp.Summary.TotalRevenue,
TotalQuantity: resp.Summary.TotalQuantity,
CategoryCount: resp.Summary.CategoryCount,
@@ -19,6 +19,7 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C
Description: req.Description,
ImageURL: nil,
Order: order,
OwnerFeePercent: req.OwnerFeePercent,
}
}
@@ -30,7 +31,11 @@ func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.U
OutletID: req.OutletID,
ParentID: req.ParentID,
Order: req.Order,
OwnerFeePercent: req.OwnerFeePercent,
IsActive: nil,
ClearParentID: req.ClearParentID,
ClearOwnerFeePercent: req.ClearOwnerFeePercent,
}
}
@@ -49,6 +54,7 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
Description: cat.Description,
BusinessType: "restaurant",
Order: cat.Order,
OwnerFeePercent: cat.OwnerFeePercent,
Metadata: map[string]interface{}{},
CreatedAt: cat.CreatedAt,
UpdatedAt: cat.UpdatedAt,
+9 -1
View File
@@ -37,6 +37,10 @@ func (v *CategoryValidatorImpl) ValidateCreateCategoryRequest(req *contract.Crea
return errors.New("description cannot exceed 1000 characters"), constants.MalformedFieldErrorCode
}
if req.OwnerFeePercent != nil && (*req.OwnerFeePercent < 0 || *req.OwnerFeePercent > 100) {
return errors.New("owner_fee_percent must be between 0 and 100"), constants.MalformedFieldErrorCode
}
if req.BusinessType != nil && strings.TrimSpace(*req.BusinessType) != "" {
validBusinessTypes := map[string]bool{
"restaurant": true,
@@ -59,7 +63,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
}
// At least one field should be provided for update
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil {
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil && req.OwnerFeePercent == nil && req.Order == nil && req.OutletID == nil && !req.ClearParentID && !req.ClearOwnerFeePercent {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
@@ -76,6 +80,10 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
return errors.New("description cannot exceed 1000 characters"), constants.MalformedFieldErrorCode
}
if req.OwnerFeePercent != nil && (*req.OwnerFeePercent < 0 || *req.OwnerFeePercent > 100) {
return errors.New("owner_fee_percent must be between 0 and 100"), constants.MalformedFieldErrorCode
}
if req.BusinessType != nil && strings.TrimSpace(*req.BusinessType) != "" {
validBusinessTypes := map[string]bool{
"restaurant": true,
@@ -0,0 +1 @@
ALTER TABLE categories DROP COLUMN owner_fee_percent;
@@ -0,0 +1,4 @@
-- The owner's share of revenue in the parent category budget report. NULL keeps the
-- default share, so only the parent categories with a different fee carry a value.
ALTER TABLE categories ADD COLUMN owner_fee_percent NUMERIC(5,2)
CONSTRAINT chk_categories_owner_fee_percent CHECK (owner_fee_percent >= 0 AND owner_fee_percent <= 100);