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
+33 -19
View File
@@ -266,16 +266,27 @@ type ProductAnalyticsPerParentCategoryResponse struct {
}
type ProductAnalyticsPerParentCategoryData struct {
ParentCategoryID uuid.UUID `json:"parent_category_id"`
ParentCategoryName string `json:"parent_category_name"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
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"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
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,14 +313,17 @@ type ParentCategoryAnalyticsDetailResponse struct {
}
type ParentCategoryAnalyticsDetailSummary struct {
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
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"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
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"`
}
+77 -26
View File
@@ -1,29 +1,79 @@
package contract
import (
"bytes"
"encoding/json"
"time"
"github.com/google/uuid"
)
type CreateCategoryRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Name string `json:"name" validate:"required,min=1,max=255"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
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"`
}
type UpdateCategoryRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
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 {
@@ -39,18 +89,19 @@ type ListCategoriesRequest struct {
// Category Response DTOs
type CategoryResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
ParentName *string `json:"parent_name,omitempty"`
Name string `json:"name"`
Description *string `json:"description"`
BusinessType string `json:"business_type"`
Order int `json:"order"`
Metadata map[string]interface{} `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
ParentName *string `json:"parent_name,omitempty"`
Name string `json:"name"`
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"`
}
type ListCategoriesResponse struct {
@@ -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))
}