Main #41
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user