cart and checkout
This commit is contained in:
+367
-5
@@ -2,6 +2,8 @@ package promotions
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
@@ -28,7 +30,7 @@ type Effect interface {
|
||||
// Apply mutates the cart for a qualifying action and returns the discount it
|
||||
// took (nil for non-monetary effects such as free shipping) plus whether
|
||||
// anything took effect worth recording.
|
||||
Apply(g *cart.CartGrain, a Action) (discount *cart.Price, applied bool)
|
||||
Apply(g *cart.CartGrain, rule PromotionRule, a Action) (discount *cart.Price, applied bool)
|
||||
// Progress reports how far the cart is from (further) unlocking this action,
|
||||
// as an open key/value payload. qualified says whether the owning rule
|
||||
// currently applies, letting the effect distinguish "remaining to unlock"
|
||||
@@ -43,6 +45,8 @@ func defaultEffects() map[ActionType]Effect {
|
||||
ActionPercentageDiscount: percentageDiscountEffect{},
|
||||
ActionTieredDiscount: tieredDiscountEffect{},
|
||||
ActionFreeShipping: freeShippingEffect{},
|
||||
ActionBuyXGetY: buyXGetYEffect{},
|
||||
ActionBundleDiscount: bundleDiscountEffect{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +76,7 @@ func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationR
|
||||
}
|
||||
recorded := false
|
||||
if res.Applicable {
|
||||
if discount, applied := eff.Apply(g, a); applied {
|
||||
if discount, applied := eff.Apply(g, res.Rule, a); applied {
|
||||
entry.Discount = discount
|
||||
recorded = true
|
||||
}
|
||||
@@ -106,7 +110,7 @@ type percentageDiscountEffect struct{}
|
||||
|
||||
func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount }
|
||||
|
||||
func (percentageDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
|
||||
func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||
d := applyPercentageDiscount(g, percentFromValue(a.Value))
|
||||
return d, d != nil
|
||||
}
|
||||
@@ -124,7 +128,7 @@ type freeShippingEffect struct{}
|
||||
|
||||
func (freeShippingEffect) Type() ActionType { return ActionFreeShipping }
|
||||
|
||||
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ Action) (*cart.Price, bool) {
|
||||
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, bool) {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
@@ -143,7 +147,7 @@ type tieredDiscountEffect struct{}
|
||||
|
||||
func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount }
|
||||
|
||||
func (tieredDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
|
||||
func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||
d := applyTieredDiscount(g, a)
|
||||
return d, d != nil
|
||||
}
|
||||
@@ -366,3 +370,361 @@ func firstNum(m map[string]interface{}, keys ...string) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// buyXGetYEffect implementation
|
||||
// ----------------------------
|
||||
|
||||
type buyXGetYEffect struct{}
|
||||
|
||||
func (buyXGetYEffect) Type() ActionType { return ActionBuyXGetY }
|
||||
|
||||
type unitItem struct {
|
||||
item *cart.CartItem
|
||||
price int64
|
||||
}
|
||||
|
||||
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) {
|
||||
config := a.Config
|
||||
buy := int(firstNum(config, "buy"))
|
||||
if buy <= 0 {
|
||||
buy = 1
|
||||
}
|
||||
get := int(firstNum(config, "get"))
|
||||
if get <= 0 {
|
||||
get = 1
|
||||
}
|
||||
discount := firstNum(config, "discount")
|
||||
if discount <= 0 {
|
||||
discount = 100.0 // default free
|
||||
}
|
||||
|
||||
eligible := eligibleItems(rule, g)
|
||||
var units []unitItem
|
||||
for _, item := range eligible {
|
||||
for k := uint16(0); k < item.Quantity; k++ {
|
||||
units = append(units, unitItem{
|
||||
item: item,
|
||||
price: item.Price.IncVat,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
n := len(units)
|
||||
numFree := (n / (buy + get)) * get
|
||||
if numFree <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Sort units by price ascending so we discount the cheapest ones
|
||||
slices.SortFunc(units, func(x, y unitItem) int {
|
||||
if x.price < y.price {
|
||||
return -1
|
||||
}
|
||||
if x.price > y.price {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
totalDiscount := cart.NewPrice()
|
||||
for i := 0; i < numFree; i++ {
|
||||
unitDiscount := scalePrice(&units[i].item.Price, discount)
|
||||
totalDiscount.Add(*unitDiscount)
|
||||
}
|
||||
|
||||
if totalDiscount.IncVat <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
// Never discount below zero.
|
||||
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||
totalDiscount = g.TotalPrice
|
||||
}
|
||||
g.TotalDiscount.Add(*totalDiscount)
|
||||
g.TotalPrice.Subtract(*totalDiscount)
|
||||
return totalDiscount, true
|
||||
}
|
||||
|
||||
func (buyXGetYEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// bundleDiscountEffect implementation
|
||||
// ----------------------------
|
||||
|
||||
type bundleDiscountEffect struct{}
|
||||
|
||||
func (bundleDiscountEffect) Type() ActionType { return ActionBundleDiscount }
|
||||
|
||||
func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||
if a.BundleConfig == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 1. Represent all cart items as individual units
|
||||
type bundleUnit struct {
|
||||
item *cart.CartItem
|
||||
used bool
|
||||
}
|
||||
var units []*bundleUnit
|
||||
for _, item := range g.Items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
for k := uint16(0); k < item.Quantity; k++ {
|
||||
units = append(units, &bundleUnit{
|
||||
item: item,
|
||||
used: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Greedy match bundles
|
||||
var formedBundles [][]*cart.CartItem
|
||||
for {
|
||||
// Attempt to form one bundle
|
||||
var matchedUnits []*bundleUnit
|
||||
possible := true
|
||||
|
||||
for _, container := range a.BundleConfig.Containers {
|
||||
needed := container.Quantity
|
||||
found := 0
|
||||
var containerMatched []*bundleUnit
|
||||
|
||||
// Find unused units matching container's qualifyingRules
|
||||
for _, u := range units {
|
||||
if u.used {
|
||||
continue
|
||||
}
|
||||
// Check if this unit is already matched in this bundle instance to avoid double counting
|
||||
alreadyMatched := false
|
||||
for _, mu := range matchedUnits {
|
||||
if mu == u {
|
||||
alreadyMatched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if alreadyMatched {
|
||||
continue
|
||||
}
|
||||
|
||||
if matchQualifyingRule(container.QualifyingRules, u.item) {
|
||||
containerMatched = append(containerMatched, u)
|
||||
found++
|
||||
if found == needed {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found < needed {
|
||||
if a.BundleConfig.RequireAllContainers {
|
||||
possible = false
|
||||
break
|
||||
}
|
||||
} else {
|
||||
matchedUnits = append(matchedUnits, containerMatched...)
|
||||
}
|
||||
}
|
||||
|
||||
if !possible || len(matchedUnits) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Mark matched units as used
|
||||
var bundleItems []*cart.CartItem
|
||||
for _, mu := range matchedUnits {
|
||||
mu.used = true
|
||||
bundleItems = append(bundleItems, mu.item)
|
||||
}
|
||||
formedBundles = append(formedBundles, bundleItems)
|
||||
}
|
||||
|
||||
if len(formedBundles) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
totalDiscount := cart.NewPrice()
|
||||
for _, bundle := range formedBundles {
|
||||
// Calculate the original bundle price
|
||||
bundlePrice := cart.NewPrice()
|
||||
for _, item := range bundle {
|
||||
// Add a single unit's price
|
||||
bundlePrice.Add(item.Price)
|
||||
}
|
||||
|
||||
if bundlePrice.IncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var bundleDiscount *cart.Price
|
||||
switch a.BundleConfig.Pricing.Type {
|
||||
case "fixed_price":
|
||||
targetPriceOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
||||
if bundlePrice.IncVat > targetPriceOre {
|
||||
pct := float64(bundlePrice.IncVat-targetPriceOre) / float64(bundlePrice.IncVat) * 100
|
||||
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||
}
|
||||
case "percentage_discount":
|
||||
bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value)
|
||||
case "fixed_discount":
|
||||
discountOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
||||
pct := float64(discountOre) / float64(bundlePrice.IncVat) * 100
|
||||
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||
}
|
||||
|
||||
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
|
||||
totalDiscount.Add(*bundleDiscount)
|
||||
}
|
||||
}
|
||||
|
||||
if totalDiscount.IncVat <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
// Never discount below zero.
|
||||
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||
totalDiscount = g.TotalPrice
|
||||
}
|
||||
g.TotalDiscount.Add(*totalDiscount)
|
||||
g.TotalPrice.Subtract(*totalDiscount)
|
||||
return totalDiscount, true
|
||||
}
|
||||
|
||||
func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Helpers
|
||||
// ----------------------------
|
||||
|
||||
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
|
||||
var categories []string
|
||||
var productIDs []string
|
||||
|
||||
var walk func(conds Conditions)
|
||||
walk = func(conds Conditions) {
|
||||
for _, c := range conds {
|
||||
switch v := c.(type) {
|
||||
case ConditionGroup:
|
||||
walk(v.Conditions)
|
||||
case BaseCondition:
|
||||
if v.Type == CondProductCategory {
|
||||
if s, ok := v.Value.AsString(); ok {
|
||||
categories = append(categories, strings.ToLower(s))
|
||||
} else if arr, ok := v.Value.AsStringSlice(); ok {
|
||||
for _, s := range arr {
|
||||
categories = append(categories, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
} else if v.Type == CondProductID {
|
||||
if s, ok := v.Value.AsString(); ok {
|
||||
productIDs = append(productIDs, strings.ToLower(s))
|
||||
} else if arr, ok := v.Value.AsStringSlice(); ok {
|
||||
for _, s := range arr {
|
||||
productIDs = append(productIDs, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(rule.Conditions)
|
||||
|
||||
var out []*cart.CartItem
|
||||
for _, item := range g.Items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
match := true
|
||||
if len(categories) > 0 {
|
||||
cat := ""
|
||||
if item.Meta != nil {
|
||||
cat = strings.ToLower(item.Meta.Category)
|
||||
}
|
||||
found := false
|
||||
for _, c := range categories {
|
||||
if c == cat {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
if len(productIDs) > 0 {
|
||||
sku := strings.ToLower(item.Sku)
|
||||
found := false
|
||||
for _, p := range productIDs {
|
||||
if p == sku {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
if match {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchQualifyingRule(rule BundleQualifyingRules, item *cart.CartItem) bool {
|
||||
if item == nil {
|
||||
return false
|
||||
}
|
||||
valStr, isStr := rule.Value.(string)
|
||||
var valSlice []string
|
||||
if !isStr {
|
||||
if rawSlice, ok := rule.Value.([]interface{}); ok {
|
||||
for _, val := range rawSlice {
|
||||
if s, ok := val.(string); ok {
|
||||
valSlice = append(valSlice, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
} else if strSlice, ok := rule.Value.([]string); ok {
|
||||
for _, s := range strSlice {
|
||||
valSlice = append(valSlice, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
valStr = strings.ToLower(valStr)
|
||||
}
|
||||
|
||||
switch rule.Type {
|
||||
case "category":
|
||||
if item.Meta == nil {
|
||||
return false
|
||||
}
|
||||
cat := strings.ToLower(item.Meta.Category)
|
||||
if isStr {
|
||||
return cat == valStr
|
||||
}
|
||||
for _, c := range valSlice {
|
||||
if cat == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "product_ids":
|
||||
sku := strings.ToLower(item.Sku)
|
||||
if isStr {
|
||||
return sku == valStr
|
||||
}
|
||||
for _, s := range valSlice {
|
||||
if sku == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "all":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user