731 lines
20 KiB
Go
731 lines
20 KiB
Go
package promotions
|
|
|
|
import (
|
|
"math"
|
|
"slices"
|
|
"strings"
|
|
|
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
|
)
|
|
|
|
// ----------------------------
|
|
// Effects: apply + progress
|
|
// ----------------------------
|
|
//
|
|
// The evaluation engine (eval.go) decides *which* rules apply to a cart but
|
|
// deliberately does not mutate it. Turning a matched action into actual öre off
|
|
// the total — and describing how close an unmatched (or partially matched)
|
|
// action is to its next reward — happens here, behind the Effect interface.
|
|
//
|
|
// Every action type is one Effect. ApplyResults walks the rules and, for each
|
|
// action, asks its Effect to (a) apply itself if the rule qualifies and (b)
|
|
// report progress. Both contributions land in cart.AppliedPromotion, so the
|
|
// result exposes "you saved X" and "spend Y more for ..." through one list, for
|
|
// any effect type — including progress on an already-applied effect.
|
|
|
|
// Effect is the behaviour for a single promotion action type.
|
|
type Effect interface {
|
|
// Type is the action type this effect handles.
|
|
Type() ActionType
|
|
// 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, 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"
|
|
// (pending) from "remaining to the next tier" (already applied). ok=false
|
|
// means there is nothing worth surfacing.
|
|
Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool)
|
|
}
|
|
|
|
// defaultEffects returns the built-in action handlers.
|
|
func defaultEffects() map[ActionType]Effect {
|
|
return map[ActionType]Effect{
|
|
ActionPercentageDiscount: percentageDiscountEffect{},
|
|
ActionTieredDiscount: tieredDiscountEffect{},
|
|
ActionFreeShipping: freeShippingEffect{},
|
|
ActionBuyXGetY: buyXGetYEffect{},
|
|
ActionBundleDiscount: bundleDiscountEffect{},
|
|
}
|
|
}
|
|
|
|
// ApplyResults applies the qualifying rules' actions to the cart in priority
|
|
// order and records every effect — applied or pending — in g.AppliedPromotions.
|
|
// An applied entry carries its Discount; a pending entry carries Pending=true.
|
|
// Either kind may carry a Progress payload. Must be called after g.UpdateTotals().
|
|
//
|
|
// ctx is the same context used for EvaluateAll; progress is computed against the
|
|
// pre-promotion totals it captured.
|
|
func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationResult, ctx *PromotionEvalContext) {
|
|
if g == nil || g.TotalPrice == nil {
|
|
return
|
|
}
|
|
for _, res := range orderedByPriority(results) {
|
|
for _, a := range res.Rule.Actions {
|
|
eff, ok := s.effects[a.Type]
|
|
if !ok {
|
|
continue
|
|
}
|
|
entry := cart.AppliedPromotion{
|
|
PromotionId: res.Rule.ID,
|
|
Name: res.Rule.Name,
|
|
ActionId: a.ID,
|
|
Type: string(a.Type),
|
|
Label: actionLabel(a),
|
|
}
|
|
recorded := false
|
|
if res.Applicable {
|
|
if discount, applied := eff.Apply(g, res.Rule, a); applied {
|
|
entry.Discount = discount
|
|
recorded = true
|
|
}
|
|
}
|
|
if progress, ok := eff.Progress(s, res.Rule, a, ctx, res.Applicable); ok {
|
|
entry.Progress = progress
|
|
entry.Pending = !res.Applicable
|
|
recorded = true
|
|
}
|
|
if recorded {
|
|
g.AppliedPromotions = append(g.AppliedPromotions, entry)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func actionLabel(a Action) string {
|
|
if a.Label != nil {
|
|
return *a.Label
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ----------------------------
|
|
// Built-in effects
|
|
// ----------------------------
|
|
|
|
// percentageDiscountEffect: a flat percentage off the whole cart. It can only be
|
|
// on or off, so its only progress is "spend X more to reach the threshold".
|
|
type percentageDiscountEffect struct{}
|
|
|
|
func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount }
|
|
|
|
func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
|
d := applyPercentageDiscount(g, percentFromValue(a.Value))
|
|
return d, d != nil
|
|
}
|
|
|
|
func (percentageDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
|
if qualified {
|
|
return nil, false
|
|
}
|
|
return s.cartTotalProgress(rule, ctx)
|
|
}
|
|
|
|
// freeShippingEffect: no cart-side amount (the fee lives in checkout). Applying
|
|
// it just marks qualification; below the threshold it reports the remaining gap.
|
|
type freeShippingEffect struct{}
|
|
|
|
func (freeShippingEffect) Type() ActionType { return ActionFreeShipping }
|
|
|
|
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, bool) {
|
|
return nil, true
|
|
}
|
|
|
|
func (freeShippingEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
|
if qualified {
|
|
return nil, false
|
|
}
|
|
return s.cartTotalProgress(rule, ctx)
|
|
}
|
|
|
|
// tieredDiscountEffect: a volume discount whose percentage grows with the cart
|
|
// total. Its progress points at the *next* tier, so it reports useful "spend X
|
|
// more for Y%" data both before the first tier (pending) and between tiers
|
|
// (already applied) — the canonical progress-on-applied-effect case.
|
|
type tieredDiscountEffect struct{}
|
|
|
|
func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount }
|
|
|
|
func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
|
d := applyTieredDiscount(g, a)
|
|
return d, d != nil
|
|
}
|
|
|
|
func (tieredDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
|
tiers := parseTiers(a.Config)
|
|
if len(tiers) == 0 {
|
|
return nil, false
|
|
}
|
|
total := ctx.CartTotalIncVat
|
|
currentPct, _ := selectTier(tiers, total) // 0 when below the first tier
|
|
|
|
// Find the nearest tier whose floor is still above the current total.
|
|
nextMin := int64(0)
|
|
nextPct := 0.0
|
|
found := false
|
|
for _, t := range tiers {
|
|
if t.MinTotal > total && (!found || t.MinTotal < nextMin) {
|
|
nextMin, nextPct, found = t.MinTotal, t.Percent, true
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, false // already at or above the top tier
|
|
}
|
|
// If not yet qualified, only nudge when the total is the sole blocker.
|
|
if !qualified && !s.withinReach(rule, ctx, nextMin) {
|
|
return nil, false
|
|
}
|
|
return map[string]any{
|
|
"remaining": nextMin - total,
|
|
"threshold": nextMin,
|
|
"currentPercent": currentPct,
|
|
"nextPercent": nextPct,
|
|
}, true
|
|
}
|
|
|
|
// ----------------------------
|
|
// Shared progress / threshold helpers
|
|
// ----------------------------
|
|
|
|
// cartTotalProgress reports the gap to a rule's cart-total threshold, but only
|
|
// when that threshold is the *sole* reason the rule isn't applying — so we never
|
|
// tell a customer to spend more when a different condition (segment, date, ...)
|
|
// is the real blocker.
|
|
func (s *PromotionService) cartTotalProgress(rule PromotionRule, ctx *PromotionEvalContext) (map[string]any, bool) {
|
|
threshold, ok := cartTotalThreshold(rule)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
remaining := threshold - ctx.CartTotalIncVat
|
|
if remaining <= 0 || !s.withinReach(rule, ctx, threshold) {
|
|
return nil, false
|
|
}
|
|
return map[string]any{"remaining": remaining, "threshold": threshold}, true
|
|
}
|
|
|
|
// withinReach reports whether the rule would apply if the cart total were
|
|
// atTotal — i.e. whether the cart-total threshold is the only thing holding it
|
|
// back. Cheap: it re-runs evaluation against a copy of the context.
|
|
func (s *PromotionService) withinReach(rule PromotionRule, ctx *PromotionEvalContext, atTotal int64) bool {
|
|
probe := *ctx
|
|
probe.CartTotalIncVat = atTotal
|
|
return s.EvaluateRule(rule, &probe).Applicable
|
|
}
|
|
|
|
// cartTotalThreshold finds the lowest cart-total floor a rule requires (a
|
|
// cart_total >= / > condition), walking nested groups. Returns false when the
|
|
// rule has no such condition.
|
|
func cartTotalThreshold(rule PromotionRule) (int64, bool) {
|
|
var threshold int64
|
|
found := false
|
|
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 != CondCartTotal {
|
|
continue
|
|
}
|
|
op := normalizeOperator(string(v.Operator))
|
|
if op != string(OpGreaterOrEqual) && op != string(OpGreaterThan) {
|
|
continue
|
|
}
|
|
f, ok := v.Value.AsFloat64()
|
|
if !ok {
|
|
continue
|
|
}
|
|
t := int64(f)
|
|
if op == string(OpGreaterThan) {
|
|
t++ // strictly greater: need at least one öre past the floor
|
|
}
|
|
if !found || t < threshold {
|
|
threshold, found = t, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
walk(rule.Conditions)
|
|
return threshold, found
|
|
}
|
|
|
|
// ----------------------------
|
|
// Discount math
|
|
// ----------------------------
|
|
|
|
// DiscountTier describes one band of a tiered (volume) discount. Bounds are the
|
|
// cart total *including VAT*, in öre (the cart's native unit), matching
|
|
// PromotionEvalContext.CartTotalIncVat. MaxTotal == 0 means "no upper bound".
|
|
//
|
|
// A tier matches when MinTotal <= cartTotal && (MaxTotal == 0 || cartTotal <= MaxTotal).
|
|
// Bounds are allowed to touch (e.g. 40000 ends one tier and begins the next);
|
|
// when a total lands exactly on a shared boundary the higher tier wins, since
|
|
// selectTier keeps the last match while scanning in order.
|
|
type DiscountTier struct {
|
|
MinTotal int64
|
|
MaxTotal int64
|
|
Percent float64
|
|
}
|
|
|
|
func applyTieredDiscount(g *cart.CartGrain, a Action) *cart.Price {
|
|
tiers := parseTiers(a.Config)
|
|
if len(tiers) == 0 {
|
|
return nil
|
|
}
|
|
pct, ok := selectTier(tiers, g.TotalPrice.IncVat)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return applyPercentageDiscount(g, pct)
|
|
}
|
|
|
|
// applyPercentageDiscount removes pct% of the current cart total, preserving the
|
|
// VAT-rate breakdown proportionally, recording it in TotalDiscount and returning
|
|
// the discount applied (nil if nothing was taken off).
|
|
func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price {
|
|
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
|
return nil
|
|
}
|
|
discount := scalePrice(g.TotalPrice, pct)
|
|
if discount.IncVat <= 0 {
|
|
return nil
|
|
}
|
|
// Never discount below zero.
|
|
if discount.IncVat > g.TotalPrice.IncVat {
|
|
discount = g.TotalPrice
|
|
}
|
|
g.TotalDiscount.Add(*discount)
|
|
g.TotalPrice.Subtract(*discount)
|
|
return discount
|
|
}
|
|
|
|
// selectTier returns the discount percent for the band the total falls into.
|
|
// Scans in declared order and keeps the last match so shared boundaries favour
|
|
// the higher tier.
|
|
func selectTier(tiers []DiscountTier, total int64) (float64, bool) {
|
|
pct := 0.0
|
|
found := false
|
|
for _, t := range tiers {
|
|
if total >= t.MinTotal && (t.MaxTotal == 0 || total <= t.MaxTotal) {
|
|
pct = t.Percent
|
|
found = true
|
|
}
|
|
}
|
|
return pct, found
|
|
}
|
|
|
|
// scalePrice returns pct% of p, rounding each amount and the VAT breakdown
|
|
// independently so the parts stay consistent with the reported total.
|
|
func scalePrice(p *cart.Price, pct float64) *cart.Price {
|
|
out := cart.NewPrice()
|
|
out.IncVat = scale(p.IncVat, pct)
|
|
for rate, amount := range p.VatRates {
|
|
out.VatRates[rate] = scale(amount, pct)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func scale(v int64, pct float64) int64 {
|
|
return int64(math.Round(float64(v) * pct / 100.0))
|
|
}
|
|
|
|
// parseTiers reads the "tiers" array out of an Action.Config. Because configs
|
|
// are decoded via encoding/json into map[string]interface{}, every element is a
|
|
// map[string]interface{} and every number is a float64. Accepts both
|
|
// minTotal/maxTotal/percent and the snake_case min_total/max_total/discount_percent.
|
|
func parseTiers(config map[string]interface{}) []DiscountTier {
|
|
raw, ok := config["tiers"].([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
tiers := make([]DiscountTier, 0, len(raw))
|
|
for _, r := range raw {
|
|
m, ok := r.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
tiers = append(tiers, DiscountTier{
|
|
MinTotal: int64(firstNum(m, "minTotal", "min_total")),
|
|
MaxTotal: int64(firstNum(m, "maxTotal", "max_total")),
|
|
Percent: firstNum(m, "percent", "discount_percent"),
|
|
})
|
|
}
|
|
return tiers
|
|
}
|
|
|
|
func percentFromValue(v interface{}) float64 {
|
|
if f, ok := v.(float64); ok {
|
|
return f
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func firstNum(m map[string]interface{}, keys ...string) float64 {
|
|
for _, k := range keys {
|
|
if f, ok := m[k].(float64); ok {
|
|
return f
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|