725 lines
19 KiB
Go
725 lines
19 KiB
Go
package promotions
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.k6n.net/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 {
|
|
if slices.ContainsFunc(ctx.Items, pred) {
|
|
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 {
|
|
return slices.Contains(list, v)
|
|
}
|
|
|
|
// ----------------------------
|
|
// 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
|
|
// ----------------------------
|