eval
This commit is contained in:
730
pkg/promotions/eval.go
Normal file
730
pkg/promotions/eval.go
Normal file
@@ -0,0 +1,730 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
var errInvalidTimeFormat = errors.New("invalid time format")
|
||||
|
||||
// Tracer allows callers to receive structured debug information during evaluation.
|
||||
type Tracer interface {
|
||||
Trace(event string, data map[string]any)
|
||||
}
|
||||
|
||||
// NoopTracer is used when no tracer provided.
|
||||
type NoopTracer struct{}
|
||||
|
||||
func (NoopTracer) Trace(string, map[string]any) {}
|
||||
|
||||
// PromotionItem is a lightweight abstraction derived from cart.CartItem
|
||||
// for the purpose of promotion condition evaluation.
|
||||
type PromotionItem struct {
|
||||
SKU string
|
||||
Quantity int
|
||||
Category string
|
||||
PriceIncVat int64
|
||||
}
|
||||
|
||||
// PromotionEvalContext carries all dynamic data required to evaluate promotion
|
||||
// conditions. It can be constructed from a cart.CartGrain plus optional
|
||||
// customer/order metadata.
|
||||
type PromotionEvalContext struct {
|
||||
CartTotalIncVat int64
|
||||
TotalItemQuantity int
|
||||
Items []PromotionItem
|
||||
CustomerSegment string
|
||||
CustomerLifetimeValue float64
|
||||
OrderCount int
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// ContextOption allows customization of fields when building a PromotionEvalContext.
|
||||
type ContextOption func(*PromotionEvalContext)
|
||||
|
||||
// WithCustomerSegment sets the customer segment.
|
||||
func WithCustomerSegment(seg string) ContextOption {
|
||||
return func(c *PromotionEvalContext) { c.CustomerSegment = seg }
|
||||
}
|
||||
|
||||
// WithCustomerLifetimeValue sets lifetime value metric.
|
||||
func WithCustomerLifetimeValue(v float64) ContextOption {
|
||||
return func(c *PromotionEvalContext) { c.CustomerLifetimeValue = v }
|
||||
}
|
||||
|
||||
// WithOrderCount sets historical order count.
|
||||
func WithOrderCount(n int) ContextOption {
|
||||
return func(c *PromotionEvalContext) { c.OrderCount = n }
|
||||
}
|
||||
|
||||
// WithNow overrides the timestamp used for date/time related conditions.
|
||||
func WithNow(t time.Time) ContextOption {
|
||||
return func(c *PromotionEvalContext) { c.Now = t }
|
||||
}
|
||||
|
||||
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
|
||||
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
|
||||
ctx := &PromotionEvalContext{
|
||||
Items: make([]PromotionItem, 0, len(g.Items)),
|
||||
CartTotalIncVat: 0,
|
||||
TotalItemQuantity: 0,
|
||||
Now: time.Now(),
|
||||
}
|
||||
if g.TotalPrice != nil {
|
||||
ctx.CartTotalIncVat = g.TotalPrice.IncVat
|
||||
}
|
||||
for _, it := range g.Items {
|
||||
category := ""
|
||||
if it.Meta != nil {
|
||||
category = it.Meta.Category
|
||||
}
|
||||
ctx.Items = append(ctx.Items, PromotionItem{
|
||||
SKU: it.Sku,
|
||||
Quantity: it.Quantity,
|
||||
Category: strings.ToLower(category),
|
||||
PriceIncVat: it.Price.IncVat,
|
||||
})
|
||||
ctx.TotalItemQuantity += it.Quantity
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(ctx)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// PromotionService evaluates PromotionRules against a PromotionEvalContext.
|
||||
type PromotionService struct {
|
||||
tracer Tracer
|
||||
}
|
||||
|
||||
// NewPromotionService constructs a PromotionService with an optional tracer.
|
||||
func NewPromotionService(t Tracer) *PromotionService {
|
||||
if t == nil {
|
||||
t = NoopTracer{}
|
||||
}
|
||||
return &PromotionService{tracer: t}
|
||||
}
|
||||
|
||||
// EvaluationResult holds the outcome of evaluating a single rule.
|
||||
type EvaluationResult struct {
|
||||
Rule PromotionRule
|
||||
Applicable bool
|
||||
FailedReason string
|
||||
MatchedActions []Action
|
||||
}
|
||||
|
||||
// EvaluateRule determines if a single PromotionRule applies to the provided context.
|
||||
// Returns an EvaluationResult with applicability and actions (if applicable).
|
||||
func (s *PromotionService) EvaluateRule(rule PromotionRule, ctx *PromotionEvalContext) EvaluationResult {
|
||||
s.tracer.Trace("rule_start", map[string]any{
|
||||
"rule_id": rule.ID,
|
||||
"priority": rule.Priority,
|
||||
"status": rule.Status,
|
||||
"startDate": rule.StartDate,
|
||||
"endDate": rule.EndDate,
|
||||
})
|
||||
// Status gate
|
||||
now := ctx.Now
|
||||
switch rule.Status {
|
||||
case StatusInactive, StatusExpired:
|
||||
s.tracer.Trace("rule_skip_status", map[string]any{"rule_id": rule.ID, "status": rule.Status})
|
||||
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "status"}
|
||||
case StatusScheduled:
|
||||
// Allow scheduled only if current time >= startDate (and within endDate if present)
|
||||
}
|
||||
// Date window checks (if parseable)
|
||||
if rule.StartDate != "" {
|
||||
if tStart, err := parseDate(rule.StartDate); err == nil {
|
||||
if now.Before(tStart) {
|
||||
s.tracer.Trace("rule_skip_before_start", map[string]any{"rule_id": rule.ID})
|
||||
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "before_start"}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rule.EndDate != nil && *rule.EndDate != "" {
|
||||
if tEnd, err := parseDate(*rule.EndDate); err == nil {
|
||||
if now.After(tEnd.Add(23*time.Hour + 59*time.Minute + 59*time.Second)) { // inclusive day
|
||||
s.tracer.Trace("rule_skip_after_end", map[string]any{"rule_id": rule.ID})
|
||||
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "after_end"}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Usage limit
|
||||
if rule.UsageLimit != nil && rule.UsageCount >= *rule.UsageLimit {
|
||||
s.tracer.Trace("rule_skip_usage_limit", map[string]any{"rule_id": rule.ID, "usageCount": rule.UsageCount, "limit": *rule.UsageLimit})
|
||||
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "usage_limit_exhausted"}
|
||||
}
|
||||
|
||||
if !evaluateConditionsTrace(rule.Conditions, ctx, s.tracer, rule.ID) {
|
||||
s.tracer.Trace("rule_conditions_failed", map[string]any{"rule_id": rule.ID})
|
||||
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "conditions"}
|
||||
}
|
||||
|
||||
s.tracer.Trace("rule_applicable", map[string]any{"rule_id": rule.ID})
|
||||
return EvaluationResult{
|
||||
Rule: rule,
|
||||
Applicable: true,
|
||||
MatchedActions: rule.Actions,
|
||||
}
|
||||
}
|
||||
|
||||
// EvaluateAll returns all applicable promotion actions given a list of rules and context.
|
||||
// Rules marked Applicable are sorted by Priority (ascending: lower number = higher precedence).
|
||||
func (s *PromotionService) EvaluateAll(rules []PromotionRule, ctx *PromotionEvalContext) ([]EvaluationResult, []Action) {
|
||||
results := make([]EvaluationResult, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
res := s.EvaluateRule(r, ctx)
|
||||
results = append(results, res)
|
||||
}
|
||||
actions := make([]Action, 0)
|
||||
for _, res := range orderedByPriority(results) {
|
||||
if res.Applicable {
|
||||
s.tracer.Trace("actions_add", map[string]any{
|
||||
"rule_id": res.Rule.ID,
|
||||
"count": len(res.MatchedActions),
|
||||
"priority": res.Rule.Priority,
|
||||
})
|
||||
actions = append(actions, res.MatchedActions...)
|
||||
}
|
||||
}
|
||||
s.tracer.Trace("evaluation_complete", map[string]any{
|
||||
"rules_total": len(rules),
|
||||
"actions": len(actions),
|
||||
})
|
||||
return results, actions
|
||||
}
|
||||
|
||||
// orderedByPriority returns results sorted by PromotionRule.Priority ascending (stable).
|
||||
func orderedByPriority(in []EvaluationResult) []EvaluationResult {
|
||||
out := make([]EvaluationResult, len(in))
|
||||
copy(out, in)
|
||||
for i := 1; i < len(out); i++ {
|
||||
j := i
|
||||
for j > 0 && out[j-1].Rule.Priority > out[j].Rule.Priority {
|
||||
out[j-1], out[j] = out[j], out[j-1]
|
||||
j--
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Condition evaluation (with tracing)
|
||||
// ----------------------------
|
||||
|
||||
func evaluateConditionsTrace(conds Conditions, ctx *PromotionEvalContext, t Tracer, ruleID string) bool {
|
||||
for idx, c := range conds {
|
||||
if !evaluateConditionTrace(c, ctx, t, ruleID, fmt.Sprintf("root[%d]", idx)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func evaluateConditionTrace(c Condition, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool {
|
||||
if grp, ok := c.(ConditionGroup); ok {
|
||||
return evaluateGroupTrace(grp, ctx, t, ruleID, path)
|
||||
}
|
||||
bc, ok := c.(BaseCondition)
|
||||
if !ok {
|
||||
t.Trace("cond_invalid_type", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return false
|
||||
}
|
||||
res := evaluateBaseCondition(bc, ctx)
|
||||
t.Trace("cond_base", map[string]any{
|
||||
"rule_id": ruleID,
|
||||
"path": path,
|
||||
"type": bc.Type,
|
||||
"op": bc.Operator,
|
||||
"value": bc.Value.String(),
|
||||
"result": res,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
func evaluateGroupTrace(g ConditionGroup, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool {
|
||||
op := normalizeLogicOperator(string(g.Operator))
|
||||
if len(g.Conditions) == 0 {
|
||||
t.Trace("cond_group_empty", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return true
|
||||
}
|
||||
if op == string(LogicAND) {
|
||||
for i, child := range g.Conditions {
|
||||
if !evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".AND[%d]", i)) {
|
||||
t.Trace("cond_group_and_fail", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return false
|
||||
}
|
||||
}
|
||||
t.Trace("cond_group_and_pass", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return true
|
||||
}
|
||||
for i, child := range g.Conditions {
|
||||
if evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".OR[%d]", i)) {
|
||||
t.Trace("cond_group_or_pass", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return true
|
||||
}
|
||||
}
|
||||
t.Trace("cond_group_or_fail", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return false
|
||||
}
|
||||
|
||||
// Fallback non-traced evaluation (used internally by traced path)
|
||||
func evaluateConditions(conds Conditions, ctx *PromotionEvalContext) bool {
|
||||
for _, c := range conds {
|
||||
if !evaluateCondition(c, ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func evaluateCondition(c Condition, ctx *PromotionEvalContext) bool {
|
||||
if grp, ok := c.(ConditionGroup); ok {
|
||||
return evaluateGroup(grp, ctx)
|
||||
}
|
||||
bc, ok := c.(BaseCondition)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return evaluateBaseCondition(bc, ctx)
|
||||
}
|
||||
|
||||
func evaluateGroup(g ConditionGroup, ctx *PromotionEvalContext) bool {
|
||||
op := normalizeLogicOperator(string(g.Operator))
|
||||
if len(g.Conditions) == 0 {
|
||||
return true
|
||||
}
|
||||
if op == string(LogicAND) {
|
||||
for _, child := range g.Conditions {
|
||||
if !evaluateCondition(child, ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, child := range g.Conditions {
|
||||
if evaluateCondition(child, ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||
switch b.Type {
|
||||
case CondCartTotal:
|
||||
return evalNumberCompare(float64(ctx.CartTotalIncVat), b)
|
||||
case CondItemQuantity:
|
||||
return evalNumberCompare(float64(ctx.TotalItemQuantity), b)
|
||||
case CondCustomerSegment:
|
||||
return evalStringCompare(ctx.CustomerSegment, b)
|
||||
case CondProductCategory:
|
||||
return evalAnyItemMatch(func(it PromotionItem) bool {
|
||||
return evalValueAgainstTarget(strings.ToLower(it.Category), b)
|
||||
}, b, ctx)
|
||||
case CondProductID:
|
||||
return evalAnyItemMatch(func(it PromotionItem) bool {
|
||||
return evalValueAgainstTarget(strings.ToLower(it.SKU), b)
|
||||
}, b, ctx)
|
||||
case CondCustomerLifetime:
|
||||
return evalNumberCompare(ctx.CustomerLifetimeValue, b)
|
||||
case CondOrderCount:
|
||||
return evalNumberCompare(float64(ctx.OrderCount), b)
|
||||
case CondDateRange:
|
||||
return evalDateRange(ctx.Now, b)
|
||||
case CondDayOfWeek:
|
||||
return evalDayOfWeek(ctx.Now, b)
|
||||
case CondTimeOfDay:
|
||||
return evalTimeOfDay(ctx.Now, b)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||
for _, it := range ctx.Items {
|
||||
if pred(it) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
switch normalizeOperator(string(b.Operator)) {
|
||||
case string(OpNotIn), string(OpNotContains):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Primitive evaluators
|
||||
// ----------------------------
|
||||
|
||||
func evalNumberCompare(target float64, b BaseCondition) bool {
|
||||
op := normalizeOperator(string(b.Operator))
|
||||
cond, ok := b.Value.AsFloat64()
|
||||
if !ok {
|
||||
if list, okL := b.Value.AsFloat64Slice(); okL && (op == string(OpIn) || op == string(OpNotIn)) {
|
||||
found := sliceFloatContains(list, target)
|
||||
if op == string(OpIn) {
|
||||
return found
|
||||
}
|
||||
return !found
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch op {
|
||||
case string(OpEquals):
|
||||
return target == cond
|
||||
case string(OpNotEquals):
|
||||
return target != cond
|
||||
case string(OpGreaterThan):
|
||||
return target > cond
|
||||
case string(OpLessThan):
|
||||
return target < cond
|
||||
case string(OpGreaterOrEqual):
|
||||
return target >= cond
|
||||
case string(OpLessOrEqual):
|
||||
return target <= cond
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func evalStringCompare(target string, b BaseCondition) bool {
|
||||
op := normalizeOperator(string(b.Operator))
|
||||
targetLower := strings.ToLower(target)
|
||||
|
||||
if s, ok := b.Value.AsString(); ok {
|
||||
condLower := strings.ToLower(s)
|
||||
switch op {
|
||||
case string(OpEquals):
|
||||
return targetLower == condLower
|
||||
case string(OpNotEquals):
|
||||
return targetLower != condLower
|
||||
case string(OpContains):
|
||||
return strings.Contains(targetLower, condLower)
|
||||
case string(OpNotContains):
|
||||
return !strings.Contains(targetLower, condLower)
|
||||
}
|
||||
}
|
||||
|
||||
if arr, ok := b.Value.AsStringSlice(); ok {
|
||||
switch op {
|
||||
case string(OpIn):
|
||||
for _, v := range arr {
|
||||
if targetLower == strings.ToLower(v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case string(OpNotIn):
|
||||
for _, v := range arr {
|
||||
if targetLower == strings.ToLower(v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case string(OpContains):
|
||||
for _, v := range arr {
|
||||
if strings.Contains(targetLower, strings.ToLower(v)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case string(OpNotContains):
|
||||
for _, v := range arr {
|
||||
if strings.Contains(targetLower, strings.ToLower(v)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func evalValueAgainstTarget(target string, b BaseCondition) bool {
|
||||
op := normalizeOperator(string(b.Operator))
|
||||
tLower := strings.ToLower(target)
|
||||
|
||||
if s, ok := b.Value.AsString(); ok {
|
||||
vLower := strings.ToLower(s)
|
||||
switch op {
|
||||
case string(OpEquals):
|
||||
return tLower == vLower
|
||||
case string(OpNotEquals):
|
||||
return tLower != vLower
|
||||
case string(OpContains):
|
||||
return strings.Contains(tLower, vLower)
|
||||
case string(OpNotContains):
|
||||
return !strings.Contains(tLower, vLower)
|
||||
case string(OpIn):
|
||||
return tLower == vLower
|
||||
case string(OpNotIn):
|
||||
return tLower != vLower
|
||||
}
|
||||
}
|
||||
|
||||
if list, ok := b.Value.AsStringSlice(); ok {
|
||||
found := false
|
||||
for _, v := range list {
|
||||
if tLower == strings.ToLower(v) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
switch op {
|
||||
case string(OpIn):
|
||||
return found
|
||||
case string(OpNotIn):
|
||||
return !found
|
||||
case string(OpContains):
|
||||
for _, v := range list {
|
||||
if strings.Contains(tLower, strings.ToLower(v)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case string(OpNotContains):
|
||||
for _, v := range list {
|
||||
if strings.Contains(tLower, strings.ToLower(v)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func evalDateRange(now time.Time, b BaseCondition) bool {
|
||||
var start, end time.Time
|
||||
if ss, ok := b.Value.AsStringSlice(); ok && len(ss) == 2 {
|
||||
t0, e0 := parseDate(ss[0])
|
||||
t1, e1 := parseDate(ss[1])
|
||||
if e0 != nil || e1 != nil {
|
||||
return false
|
||||
}
|
||||
start, end = t0, t1
|
||||
} else if s, ok := b.Value.AsString(); ok {
|
||||
parts := strings.Split(s, "..")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
t0, e0 := parseDate(parts[0])
|
||||
t1, e1 := parseDate(parts[1])
|
||||
if e0 != nil || e1 != nil {
|
||||
return false
|
||||
}
|
||||
start, end = t0, t1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
endInclusive := end.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||||
return (now.Equal(start) || now.After(start)) && (now.Equal(endInclusive) || now.Before(endInclusive))
|
||||
}
|
||||
|
||||
func evalDayOfWeek(now time.Time, b BaseCondition) bool {
|
||||
dow := int(now.Weekday())
|
||||
allowed := make(map[int]struct{})
|
||||
if arr, ok := b.Value.AsStringSlice(); ok {
|
||||
for _, v := range arr {
|
||||
if idx, ok := parseDayOfWeek(v); ok {
|
||||
allowed[idx] = struct{}{}
|
||||
}
|
||||
}
|
||||
} else if s, ok := b.Value.AsString(); ok {
|
||||
for _, part := range strings.Split(s, "|") {
|
||||
if idx, ok := parseDayOfWeek(strings.TrimSpace(part)); ok {
|
||||
allowed[idx] = struct{}{}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(allowed) == 0 {
|
||||
return false
|
||||
}
|
||||
op := normalizeOperator(string(b.Operator))
|
||||
_, present := allowed[dow]
|
||||
if op == string(OpIn) || op == string(OpEquals) || op == "" {
|
||||
return present
|
||||
}
|
||||
if op == string(OpNotIn) || op == string(OpNotEquals) {
|
||||
return !present
|
||||
}
|
||||
return present
|
||||
}
|
||||
|
||||
func evalTimeOfDay(now time.Time, b BaseCondition) bool {
|
||||
var startMin, endMin int
|
||||
if arr, ok := b.Value.AsStringSlice(); ok && len(arr) == 2 {
|
||||
s0, e0 := parseClock(arr[0])
|
||||
s1, e1 := parseClock(arr[1])
|
||||
if e0 != nil || e1 != nil {
|
||||
return false
|
||||
}
|
||||
startMin, endMin = s0, s1
|
||||
} else if s, ok := b.Value.AsString(); ok {
|
||||
parts := strings.Split(s, "-")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
s0, e0 := parseClock(parts[0])
|
||||
s1, e1 := parseClock(parts[1])
|
||||
if e0 != nil || e1 != nil {
|
||||
return false
|
||||
}
|
||||
startMin, endMin = s0, s1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
todayMin := now.Hour()*60 + now.Minute()
|
||||
if startMin > endMin {
|
||||
return todayMin >= startMin || todayMin <= endMin
|
||||
}
|
||||
return todayMin >= startMin && todayMin <= endMin
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Parsing helpers
|
||||
// ----------------------------
|
||||
|
||||
func parseDate(s string) (time.Time, error) {
|
||||
layouts := []string{
|
||||
"2006-01-02",
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
}
|
||||
for _, l := range layouts {
|
||||
if t, err := time.Parse(l, strings.TrimSpace(s)); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errInvalidTimeFormat
|
||||
}
|
||||
|
||||
func parseDayOfWeek(s string) (int, bool) {
|
||||
sl := strings.ToLower(strings.TrimSpace(s))
|
||||
switch sl {
|
||||
case "sun", "sunday", "0":
|
||||
return 0, true
|
||||
case "mon", "monday", "1":
|
||||
return 1, true
|
||||
case "tue", "tues", "tuesday", "2":
|
||||
return 2, true
|
||||
case "wed", "weds", "wednesday", "3":
|
||||
return 3, true
|
||||
case "thu", "thur", "thurs", "thursday", "4":
|
||||
return 4, true
|
||||
case "fri", "friday", "5":
|
||||
return 5, true
|
||||
case "sat", "saturday", "6":
|
||||
return 6, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseClock(s string) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, errInvalidTimeFormat
|
||||
}
|
||||
h, err := parsePositiveInt(parts[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
m, err := parsePositiveInt(parts[1])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return 0, errInvalidTimeFormat
|
||||
}
|
||||
return h*60 + m, nil
|
||||
}
|
||||
|
||||
func parsePositiveInt(s string) (int, error) {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, errInvalidTimeFormat
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func normalizeOperator(op string) string {
|
||||
o := strings.ToLower(strings.TrimSpace(op))
|
||||
switch o {
|
||||
case "=", "equals", "eq":
|
||||
return string(OpEquals)
|
||||
case "!=", "not_equals", "neq":
|
||||
return string(OpNotEquals)
|
||||
case ">", "greater_than", "gt":
|
||||
return string(OpGreaterThan)
|
||||
case "<", "less_than", "lt":
|
||||
return string(OpLessThan)
|
||||
case ">=", "greater_or_equal", "ge", "gte":
|
||||
return string(OpGreaterOrEqual)
|
||||
case "<=", "less_or_equal", "le", "lte":
|
||||
return string(OpLessOrEqual)
|
||||
case "contains":
|
||||
return string(OpContains)
|
||||
case "not_contains":
|
||||
return string(OpNotContains)
|
||||
case "in":
|
||||
return string(OpIn)
|
||||
case "not_in":
|
||||
return string(OpNotIn)
|
||||
default:
|
||||
return o
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLogicOperator(op string) string {
|
||||
o := strings.ToLower(strings.TrimSpace(op))
|
||||
switch o {
|
||||
case "&&", "and":
|
||||
return string(LogicAND)
|
||||
case "||", "or":
|
||||
return string(LogicOR)
|
||||
default:
|
||||
return o
|
||||
}
|
||||
}
|
||||
|
||||
func sliceFloatContains(list []float64, v float64) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Potential extension hooks
|
||||
// ----------------------------
|
||||
//
|
||||
// Future ideas:
|
||||
// - Conflict resolution strategies (e.g., best discount wins, stackable tags)
|
||||
// - Action transformation (e.g., applying tiered logic or bundles carefully)
|
||||
// - Recording evaluation traces for debugging / analytics.
|
||||
// - Tracing instrumentation for condition evaluation.
|
||||
//
|
||||
// These can be integrated by adding strategy interfaces or injecting evaluators
|
||||
// into PromotionService.
|
||||
//
|
||||
// ----------------------------
|
||||
// End of file
|
||||
// ----------------------------
|
||||
448
pkg/promotions/eval_test.go
Normal file
448
pkg/promotions/eval_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------
|
||||
|
||||
func cvNum(n float64) ConditionValue {
|
||||
b, _ := json.Marshal(n)
|
||||
return ConditionValue{Raw: b}
|
||||
}
|
||||
|
||||
func cvString(s string) ConditionValue {
|
||||
b, _ := json.Marshal(s)
|
||||
return ConditionValue{Raw: b}
|
||||
}
|
||||
|
||||
func cvStrings(ss []string) ConditionValue {
|
||||
b, _ := json.Marshal(ss)
|
||||
return ConditionValue{Raw: b}
|
||||
}
|
||||
|
||||
// testTracer captures trace events for assertions.
|
||||
type testTracer struct {
|
||||
events []traceEvent
|
||||
}
|
||||
|
||||
type traceEvent struct {
|
||||
event string
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
func (t *testTracer) Trace(event string, data map[string]any) {
|
||||
t.events = append(t.events, traceEvent{event: event, data: data})
|
||||
}
|
||||
|
||||
func (t *testTracer) HasEvent(name string) bool {
|
||||
return slices.ContainsFunc(t.events, func(e traceEvent) bool { return e.event == name })
|
||||
}
|
||||
|
||||
func (t *testTracer) Count(name string) int {
|
||||
c := 0
|
||||
for _, e := range t.events {
|
||||
if e.event == name {
|
||||
c++
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// makeCart creates a cart with given total and items (each item quantity & price IncVat).
|
||||
func makeCart(totalOverride int64, items []struct {
|
||||
sku string
|
||||
category string
|
||||
qty int
|
||||
priceInc int64
|
||||
}) *cart.CartGrain {
|
||||
g := cart.NewCartGrain(1, time.Now())
|
||||
for _, it := range items {
|
||||
p := cart.NewPriceFromIncVat(it.priceInc, 0.25)
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Id: uint32(len(g.Items) + 1),
|
||||
Sku: it.sku,
|
||||
Price: *p,
|
||||
TotalPrice: cart.Price{
|
||||
IncVat: p.IncVat * int64(it.qty),
|
||||
VatRates: p.VatRates,
|
||||
},
|
||||
Quantity: it.qty,
|
||||
Meta: &cart.ItemMeta{
|
||||
Category: it.category,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Recalculate totals
|
||||
g.UpdateTotals()
|
||||
if totalOverride >= 0 {
|
||||
g.TotalPrice.IncVat = totalOverride
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// --- Tests -----------------------------------------------------------------
|
||||
|
||||
func TestEvaluateRuleBasicAND(t *testing.T) {
|
||||
g := makeCart(12000, []struct {
|
||||
sku string
|
||||
category string
|
||||
qty int
|
||||
priceInc int64
|
||||
}{
|
||||
{"SKU-1", "summer", 2, 3000},
|
||||
{"SKU-2", "winter", 1, 6000},
|
||||
})
|
||||
|
||||
ctx := NewContextFromCart(g,
|
||||
WithCustomerSegment("vip"),
|
||||
WithOrderCount(10),
|
||||
WithCustomerLifetimeValue(1234.56),
|
||||
WithNow(time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC)),
|
||||
)
|
||||
|
||||
// Conditions: cart_total >= 10000 AND item_quantity >= 3 AND customer_segment = vip
|
||||
conds := Conditions{
|
||||
ConditionGroup{
|
||||
ID: "grp",
|
||||
Type: "group",
|
||||
Operator: LogicAND,
|
||||
Conditions: Conditions{
|
||||
BaseCondition{
|
||||
ID: "c_cart_total",
|
||||
Type: CondCartTotal,
|
||||
Operator: OpGreaterOrEqual,
|
||||
Value: cvNum(10000),
|
||||
},
|
||||
BaseCondition{
|
||||
ID: "c_item_qty",
|
||||
Type: CondItemQuantity,
|
||||
Operator: OpGreaterOrEqual,
|
||||
Value: cvNum(3),
|
||||
},
|
||||
BaseCondition{
|
||||
ID: "c_segment",
|
||||
Type: CondCustomerSegment,
|
||||
Operator: OpEquals,
|
||||
Value: cvString("vip"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rule := PromotionRule{
|
||||
ID: "rule-AND",
|
||||
Name: "VIP Summer",
|
||||
Description: "Test rule",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2024-06-01",
|
||||
EndDate: ptr("2024-06-30"),
|
||||
Conditions: conds,
|
||||
Actions: []Action{
|
||||
{ID: "a1", Type: ActionPercentageDiscount, Value: 10.0},
|
||||
},
|
||||
UsageLimit: ptrInt(100),
|
||||
UsageCount: 5,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
CreatedBy: "tester",
|
||||
}
|
||||
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if !res.Applicable {
|
||||
t.Fatalf("expected rule to be applicable, failedReason=%s", res.FailedReason)
|
||||
}
|
||||
if len(res.MatchedActions) != 1 {
|
||||
t.Fatalf("expected 1 action, got %d", len(res.MatchedActions))
|
||||
}
|
||||
if !tracer.HasEvent("rule_applicable") {
|
||||
t.Errorf("expected tracing event rule_applicable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRuleUsageLimitExhausted(t *testing.T) {
|
||||
rule := PromotionRule{
|
||||
ID: "limit",
|
||||
Name: "Limit",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{},
|
||||
UsageLimit: ptrInt(5),
|
||||
UsageCount: 5,
|
||||
}
|
||||
|
||||
ctx := &PromotionEvalContext{Now: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if res.Applicable {
|
||||
t.Fatalf("expected rule NOT applicable due to usage limit")
|
||||
}
|
||||
if res.FailedReason != "usage_limit_exhausted" {
|
||||
t.Fatalf("expected failedReason usage_limit_exhausted, got %s", res.FailedReason)
|
||||
}
|
||||
if !tracer.HasEvent("rule_skip_usage_limit") {
|
||||
t.Errorf("tracer missing rule_skip_usage_limit event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRuleDateWindow(t *testing.T) {
|
||||
// Start in future
|
||||
rule := PromotionRule{
|
||||
ID: "date",
|
||||
Name: "DateWindow",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2025-01-01",
|
||||
EndDate: ptr("2025-01-31"),
|
||||
Conditions: Conditions{},
|
||||
}
|
||||
ctx := &PromotionEvalContext{Now: time.Date(2024, 12, 15, 12, 0, 0, 0, time.UTC)}
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if res.Applicable {
|
||||
t.Fatalf("expected rule NOT applicable (before start)")
|
||||
}
|
||||
if res.FailedReason != "before_start" {
|
||||
t.Fatalf("expected failedReason before_start, got %s", res.FailedReason)
|
||||
}
|
||||
if !tracer.HasEvent("rule_skip_before_start") {
|
||||
t.Errorf("missing rule_skip_before_start trace event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateProductCategoryIN(t *testing.T) {
|
||||
g := makeCart(-1, []struct {
|
||||
sku string
|
||||
category string
|
||||
qty int
|
||||
priceInc int64
|
||||
}{
|
||||
{"A", "shoes", 1, 5000},
|
||||
{"B", "bags", 1, 7000},
|
||||
})
|
||||
|
||||
ctx := NewContextFromCart(g)
|
||||
conds := Conditions{
|
||||
BaseCondition{
|
||||
ID: "c_category",
|
||||
Type: CondProductCategory,
|
||||
Operator: OpIn,
|
||||
Value: cvStrings([]string{"shoes", "hats"}),
|
||||
},
|
||||
}
|
||||
rule := PromotionRule{
|
||||
ID: "cat-in",
|
||||
Status: StatusActive,
|
||||
Priority: 10,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: conds,
|
||||
Actions: []Action{
|
||||
{ID: "discount", Type: ActionFixedDiscount, Value: 1000},
|
||||
},
|
||||
}
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if !res.Applicable {
|
||||
t.Fatalf("expected category IN rule to apply")
|
||||
}
|
||||
if !tracer.HasEvent("rule_applicable") {
|
||||
t.Errorf("tracing missing rule_applicable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateGroupOR(t *testing.T) {
|
||||
g := makeCart(3000, []struct {
|
||||
sku string
|
||||
category string
|
||||
qty int
|
||||
priceInc int64
|
||||
}{
|
||||
{"ONE", "x", 1, 3000},
|
||||
})
|
||||
|
||||
ctx := NewContextFromCart(g)
|
||||
|
||||
// OR group: (cart_total >= 10000) OR (item_quantity >= 1)
|
||||
group := ConditionGroup{
|
||||
ID: "grp-or",
|
||||
Type: "group",
|
||||
Operator: LogicOR,
|
||||
Conditions: Conditions{
|
||||
BaseCondition{
|
||||
ID: "c_total",
|
||||
Type: CondCartTotal,
|
||||
Operator: OpGreaterOrEqual,
|
||||
Value: cvNum(10000),
|
||||
},
|
||||
BaseCondition{
|
||||
ID: "c_qty",
|
||||
Type: CondItemQuantity,
|
||||
Operator: OpGreaterOrEqual,
|
||||
Value: cvNum(1),
|
||||
},
|
||||
},
|
||||
}
|
||||
rule := PromotionRule{
|
||||
ID: "or-rule",
|
||||
Status: StatusActive,
|
||||
Priority: 5,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{group},
|
||||
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
|
||||
}
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if !res.Applicable {
|
||||
t.Fatalf("expected OR rule to apply (second condition true)")
|
||||
}
|
||||
// Ensure group pass event
|
||||
if !tracer.HasEvent("cond_group_or_pass") {
|
||||
t.Errorf("expected cond_group_or_pass trace event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAllPriorityOrdering(t *testing.T) {
|
||||
ctx := &PromotionEvalContext{
|
||||
CartTotalIncVat: 20000,
|
||||
TotalItemQuantity: 2,
|
||||
Items: []PromotionItem{
|
||||
{SKU: "X", Quantity: 2, Category: "general", PriceIncVat: 10000},
|
||||
},
|
||||
Now: time.Date(2024, 5, 10, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
// Rule A: priority 5
|
||||
ruleA := PromotionRule{
|
||||
ID: "A",
|
||||
Status: StatusActive,
|
||||
Priority: 5,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{},
|
||||
Actions: []Action{
|
||||
{ID: "actionA", Type: ActionFixedDiscount, Value: 100},
|
||||
},
|
||||
}
|
||||
|
||||
// Rule B: priority 1
|
||||
ruleB := PromotionRule{
|
||||
ID: "B",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{},
|
||||
Actions: []Action{
|
||||
{ID: "actionB", Type: ActionFixedDiscount, Value: 200},
|
||||
},
|
||||
}
|
||||
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
results, actions := svc.EvaluateAll([]PromotionRule{ruleA, ruleB}, ctx)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
if len(actions) != 2 {
|
||||
t.Fatalf("expected 2 actions, got %d", len(actions))
|
||||
}
|
||||
|
||||
// Actions should follow priority order: ruleB (1) then ruleA (5)
|
||||
if actions[0].ID != "actionB" || actions[1].ID != "actionA" {
|
||||
t.Fatalf("actions order invalid: %+v", actions)
|
||||
}
|
||||
if tracer.Count("actions_add") != 2 {
|
||||
t.Errorf("expected 2 actions_add trace events, got %d", tracer.Count("actions_add"))
|
||||
}
|
||||
if !tracer.HasEvent("evaluation_complete") {
|
||||
t.Errorf("missing evaluation_complete trace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDayOfWeekAndTimeOfDay(t *testing.T) {
|
||||
// Wednesday 14:30 UTC
|
||||
now := time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC) // 2024-06-12 is Wednesday
|
||||
ctx := &PromotionEvalContext{
|
||||
Now: now,
|
||||
}
|
||||
|
||||
condDay := BaseCondition{
|
||||
ID: "dow",
|
||||
Type: CondDayOfWeek,
|
||||
Operator: OpIn,
|
||||
Value: cvStrings([]string{"wed", "fri"}),
|
||||
}
|
||||
condTime := BaseCondition{
|
||||
ID: "tod",
|
||||
Type: CondTimeOfDay,
|
||||
Operator: OpEquals, // operator is ignored for time-of-day internally
|
||||
Value: cvString("13:00-15:00"),
|
||||
}
|
||||
|
||||
rule := PromotionRule{
|
||||
ID: "day-time",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{condDay, condTime},
|
||||
Actions: []Action{{ID: "a", Type: ActionPercentageDiscount, Value: 15}},
|
||||
}
|
||||
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if !res.Applicable {
|
||||
t.Fatalf("expected rule to apply for Wednesday 14:30 in window 13-15")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateRangeCondition(t *testing.T) {
|
||||
now := time.Date(2024, 3, 15, 9, 0, 0, 0, time.UTC)
|
||||
ctx := &PromotionEvalContext{Now: now}
|
||||
|
||||
condRange := BaseCondition{
|
||||
ID: "date_range",
|
||||
Type: CondDateRange,
|
||||
Operator: OpEquals, // not used
|
||||
Value: cvStrings([]string{"2024-03-01", "2024-03-31"}),
|
||||
}
|
||||
|
||||
rule := PromotionRule{
|
||||
ID: "range",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{condRange},
|
||||
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
|
||||
}
|
||||
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if !res.Applicable {
|
||||
t.Fatalf("expected date range rule to apply for 2024-03-15")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Utilities -------------------------------------------------------------
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
func ptrInt(i int) *int { return &i }
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// -----------------------------
|
||||
@@ -15,12 +16,12 @@ import (
|
||||
type ConditionOperator string
|
||||
|
||||
const (
|
||||
OpEquals ConditionOperator = "equals"
|
||||
OpNotEquals ConditionOperator = "not_equals"
|
||||
OpGreaterThan ConditionOperator = "greater_than"
|
||||
OpLessThan ConditionOperator = "less_than"
|
||||
OpGreaterOrEqual ConditionOperator = "greater_or_equal"
|
||||
OpLessOrEqual ConditionOperator = "less_or_equal"
|
||||
OpEquals ConditionOperator = "="
|
||||
OpNotEquals ConditionOperator = "!="
|
||||
OpGreaterThan ConditionOperator = ">"
|
||||
OpLessThan ConditionOperator = "<"
|
||||
OpGreaterOrEqual ConditionOperator = ">="
|
||||
OpLessOrEqual ConditionOperator = "<="
|
||||
OpContains ConditionOperator = "contains"
|
||||
OpNotContains ConditionOperator = "not_contains"
|
||||
OpIn ConditionOperator = "in"
|
||||
@@ -57,8 +58,8 @@ const (
|
||||
type LogicOperator string
|
||||
|
||||
const (
|
||||
LogicAND LogicOperator = "AND"
|
||||
LogicOR LogicOperator = "OR"
|
||||
LogicAND LogicOperator = "&&"
|
||||
LogicOR LogicOperator = "||"
|
||||
)
|
||||
|
||||
type PromotionStatus string
|
||||
@@ -218,7 +219,14 @@ func (g *ConditionGroup) UnmarshalJSON(b []byte) error {
|
||||
|
||||
g.ID = a.ID
|
||||
g.Type = a.Type
|
||||
g.Operator = a.Operator
|
||||
switch strings.ToLower(string(a.Operator)) {
|
||||
case "and":
|
||||
g.Operator = LogicAND
|
||||
case "or":
|
||||
g.Operator = LogicOR
|
||||
default:
|
||||
g.Operator = a.Operator
|
||||
}
|
||||
g.Conditions = conds
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package voucher
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -137,12 +138,7 @@ func (rs *RuleSet) Applies(ctx EvalContext) bool {
|
||||
|
||||
// anyItem returns true if predicate matches any item.
|
||||
func anyItem(items []Item, pred func(Item) bool) bool {
|
||||
for _, it := range items {
|
||||
if pred(it) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.ContainsFunc(items, pred)
|
||||
}
|
||||
|
||||
// ParseRules parses a rule expression into a RuleSet.
|
||||
|
||||
Reference in New Issue
Block a user