eval
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m6s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s

This commit is contained in:
matst80
2025-10-20 17:52:44 +02:00
parent 99c9f611e7
commit e1de5a00a0
4 changed files with 1197 additions and 15 deletions

730
pkg/promotions/eval.go Normal file
View 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
View 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 }

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
) )
// ----------------------------- // -----------------------------
@@ -15,12 +16,12 @@ import (
type ConditionOperator string type ConditionOperator string
const ( const (
OpEquals ConditionOperator = "equals" OpEquals ConditionOperator = "="
OpNotEquals ConditionOperator = "not_equals" OpNotEquals ConditionOperator = "!="
OpGreaterThan ConditionOperator = "greater_than" OpGreaterThan ConditionOperator = ">"
OpLessThan ConditionOperator = "less_than" OpLessThan ConditionOperator = "<"
OpGreaterOrEqual ConditionOperator = "greater_or_equal" OpGreaterOrEqual ConditionOperator = ">="
OpLessOrEqual ConditionOperator = "less_or_equal" OpLessOrEqual ConditionOperator = "<="
OpContains ConditionOperator = "contains" OpContains ConditionOperator = "contains"
OpNotContains ConditionOperator = "not_contains" OpNotContains ConditionOperator = "not_contains"
OpIn ConditionOperator = "in" OpIn ConditionOperator = "in"
@@ -57,8 +58,8 @@ const (
type LogicOperator string type LogicOperator string
const ( const (
LogicAND LogicOperator = "AND" LogicAND LogicOperator = "&&"
LogicOR LogicOperator = "OR" LogicOR LogicOperator = "||"
) )
type PromotionStatus string type PromotionStatus string
@@ -218,7 +219,14 @@ func (g *ConditionGroup) UnmarshalJSON(b []byte) error {
g.ID = a.ID g.ID = a.ID
g.Type = a.Type 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 g.Conditions = conds
return nil return nil
} }

View File

@@ -3,6 +3,7 @@ package voucher
import ( import (
"errors" "errors"
"fmt" "fmt"
"slices"
"strconv" "strconv"
"strings" "strings"
"unicode" "unicode"
@@ -137,12 +138,7 @@ func (rs *RuleSet) Applies(ctx EvalContext) bool {
// anyItem returns true if predicate matches any item. // anyItem returns true if predicate matches any item.
func anyItem(items []Item, pred func(Item) bool) bool { func anyItem(items []Item, pred func(Item) bool) bool {
for _, it := range items { return slices.ContainsFunc(items, pred)
if pred(it) {
return true
}
}
return false
} }
// ParseRules parses a rule expression into a RuleSet. // ParseRules parses a rule expression into a RuleSet.