promotions
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// ----------------------------
|
||||
// Stateless evaluation
|
||||
// ----------------------------
|
||||
//
|
||||
// Evaluate runs the promotion engine against a caller-supplied context instead
|
||||
// of a real cart, so a storefront can ask "what would these items get?" without
|
||||
// creating or mutating a cart. It builds a synthetic CartGrain from the request,
|
||||
// runs the same EvaluateAll + ApplyResults path the live cart uses, and returns
|
||||
// the resulting totals plus the applied/pending effect breakdown.
|
||||
|
||||
// EvalItem is one — possibly partial — cart line for a stateless evaluation.
|
||||
// Missing fields fall back to sensible defaults (qty 1, 25% VAT) so the engine
|
||||
// does the best it can with whatever the caller provides.
|
||||
type EvalItem struct {
|
||||
Sku string `json:"sku,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Quantity uint16 `json:"qty,omitempty"`
|
||||
PriceIncVat int64 `json:"priceIncVat,omitempty"`
|
||||
VatRate float32 `json:"vatRate,omitempty"`
|
||||
}
|
||||
|
||||
// EvaluateRequest is a stateless promotion-evaluation context. Every field is
|
||||
// optional. Items drive the cart total and the item-scoped conditions; when no
|
||||
// items are given, CartTotalIncVat synthesises a single line so total-only rules
|
||||
// (volume discounts, free-shipping thresholds) can still be previewed.
|
||||
type EvaluateRequest struct {
|
||||
Items []EvalItem `json:"items,omitempty"`
|
||||
CartTotalIncVat int64 `json:"cartTotalIncVat,omitempty"`
|
||||
CustomerSegment string `json:"customerSegment,omitempty"`
|
||||
CustomerLifetimeValue float64 `json:"customerLifetimeValue,omitempty"`
|
||||
OrderCount int `json:"orderCount,omitempty"`
|
||||
Now *time.Time `json:"now,omitempty"`
|
||||
}
|
||||
|
||||
// EvaluateResponse is the outcome of a stateless evaluation: the totals after
|
||||
// promotions and the per-promotion breakdown (applied discounts plus pending
|
||||
// "spend X more for ..." nudges).
|
||||
type EvaluateResponse struct {
|
||||
TotalPrice *cart.Price `json:"totalPrice"`
|
||||
TotalDiscount *cart.Price `json:"totalDiscount"`
|
||||
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
||||
}
|
||||
|
||||
// Evaluate runs rules against a synthetic cart built from req and returns the
|
||||
// resulting totals and effects without touching any real cart state.
|
||||
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
|
||||
g := req.toCart()
|
||||
g.UpdateTotals()
|
||||
ctx := NewContextFromCart(g, req.contextOptions()...)
|
||||
results, _ := s.EvaluateAll(rules, ctx)
|
||||
s.ApplyResults(g, results, ctx)
|
||||
return EvaluateResponse{
|
||||
TotalPrice: g.TotalPrice,
|
||||
TotalDiscount: g.TotalDiscount,
|
||||
AppliedPromotions: g.AppliedPromotions,
|
||||
}
|
||||
}
|
||||
|
||||
// toCart materialises the request into a throwaway CartGrain.
|
||||
func (req EvaluateRequest) toCart() *cart.CartGrain {
|
||||
now := time.Now()
|
||||
if req.Now != nil {
|
||||
now = *req.Now
|
||||
}
|
||||
g := cart.NewCartGrain(0, now)
|
||||
for _, it := range req.Items {
|
||||
qty := it.Quantity
|
||||
if qty == 0 {
|
||||
qty = 1
|
||||
}
|
||||
vat := it.VatRate
|
||||
if vat == 0 {
|
||||
vat = 25
|
||||
}
|
||||
item := &cart.CartItem{
|
||||
Sku: it.Sku,
|
||||
Quantity: qty,
|
||||
Price: *cart.NewPriceFromIncVat(it.PriceIncVat, vat),
|
||||
}
|
||||
if it.Category != "" {
|
||||
item.Meta = &cart.ItemMeta{Category: it.Category}
|
||||
}
|
||||
g.Items = append(g.Items, item)
|
||||
}
|
||||
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Sku: "synthetic",
|
||||
Quantity: 1,
|
||||
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, 25),
|
||||
})
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// contextOptions maps the optional customer/time fields onto context options.
|
||||
func (req EvaluateRequest) contextOptions() []ContextOption {
|
||||
var opts []ContextOption
|
||||
if req.Now != nil {
|
||||
opts = append(opts, WithNow(*req.Now))
|
||||
}
|
||||
if req.CustomerSegment != "" {
|
||||
opts = append(opts, WithCustomerSegment(req.CustomerSegment))
|
||||
}
|
||||
if req.CustomerLifetimeValue != 0 {
|
||||
opts = append(opts, WithCustomerLifetimeValue(req.CustomerLifetimeValue))
|
||||
}
|
||||
if req.OrderCount != 0 {
|
||||
opts = append(opts, WithOrderCount(req.OrderCount))
|
||||
}
|
||||
return opts
|
||||
}
|
||||
Reference in New Issue
Block a user