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
|
||||
// ----------------------------
|
||||
Reference in New Issue
Block a user