better results
This commit is contained in:
+24
-53
@@ -202,59 +202,17 @@ func main() {
|
||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||
defer span.End()
|
||||
|
||||
// Clear bypass flags first
|
||||
for _, v := range g.Vouchers {
|
||||
if v != nil {
|
||||
v.BypassedByPromotions = false
|
||||
}
|
||||
}
|
||||
|
||||
g.UpdateTotals()
|
||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||
|
||||
// Check if any qualified promotion rule has a coupon_code condition matching our vouchers
|
||||
hasBypassed := false
|
||||
for _, res := range results {
|
||||
if res.Applicable {
|
||||
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
|
||||
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
|
||||
var codes []string
|
||||
if s, ok := bc.Value.AsString(); ok {
|
||||
codes = append(codes, strings.ToLower(s))
|
||||
} else if arr, ok := bc.Value.AsStringSlice(); ok {
|
||||
for _, s := range arr {
|
||||
codes = append(codes, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
for _, code := range codes {
|
||||
for _, v := range g.Vouchers {
|
||||
if v != nil && strings.ToLower(v.Code) == code {
|
||||
if !v.BypassedByPromotions {
|
||||
v.BypassedByPromotions = true
|
||||
hasBypassed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if hasBypassed {
|
||||
// Re-evaluate with bypassed vouchers
|
||||
g.UpdateTotals()
|
||||
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||
}
|
||||
|
||||
// ApplyResults applies qualifying actions in priority order and records
|
||||
// every effect — both applied discounts and pending "spend X more for ..."
|
||||
// nudges with their progress — in g.AppliedPromotions.
|
||||
promotionService.ApplyResults(g, results, promotionCtx)
|
||||
// Canonical promotion pipeline — same call site as the
|
||||
// /promotions/evaluate-with-cart preview handler in
|
||||
// promotions_evaluate.go. Going through the shared
|
||||
// PromotionService.EvaluateAndApply means the bypass
|
||||
// loop, the re-eval trigger, and ApplyResults stay in
|
||||
// lockstep with the preview so a what-if always matches
|
||||
// post-add behavior (including the coupon+promotion-
|
||||
// overlap edge case). If you find yourself adding
|
||||
// promotion logic here, add it to EvaluateAndApply
|
||||
// instead — the preview must follow.
|
||||
promotionService.EvaluateAndApply(promotionStore.Snapshot(), g, promotions.WithNow(time.Now()))
|
||||
g.Version++
|
||||
return nil
|
||||
}),
|
||||
@@ -490,6 +448,19 @@ func main() {
|
||||
// without creating or mutating a real cart.
|
||||
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
|
||||
|
||||
// Cart-aware promotion evaluation: reads the cartid cookie the storefront
|
||||
// already maintains, fetches the actual cart grain (cross-pod via
|
||||
// GetAnywhere), merges with the request's items (the items the user is
|
||||
// considering adding), and runs the engine on the merged line list. The
|
||||
// cart is the source of truth (vouchers, customer segment, customer
|
||||
// lifetime value, total) so a serialised copy from the browser can race
|
||||
// the live cart — letting the backend read the cookie + fetch the grain
|
||||
// means exactly one source of truth and no race. Missing cookie or fetch
|
||||
// failure gracefully degrades to a stateless evaluation of the request
|
||||
// items alone. See newPromotionEvaluateWithCartHandler in
|
||||
// promotions_evaluate.go for the full contract.
|
||||
mux.HandleFunc("POST /promotions/evaluate-with-cart", newPromotionEvaluateWithCartHandler(promotionStore, promotionService, syncedServer))
|
||||
|
||||
// only for local
|
||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||
pool.AddRemote(r.PathValue("host"))
|
||||
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
)
|
||||
|
||||
@@ -13,6 +16,7 @@ import (
|
||||
// (possibly partial) evaluation context and returns the totals plus the
|
||||
// applied/pending promotion effects, without creating or mutating a real cart.
|
||||
// An empty body is treated as an empty context (evaluates against no items).
|
||||
// Pure stateless — callers compose the line list themselves.
|
||||
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req promotions.EvaluateRequest
|
||||
@@ -27,3 +31,175 @@ func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.Promot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newPromotionEvaluateWithCartHandler serves POST /promotions/evaluate-with-cart.
|
||||
// Reads the `cartid` cookie the storefront already maintains (set by
|
||||
// GET /cart on first visit), fetches the actual cart from the grain pool
|
||||
// (cross-pod via GetAnywhere so a cart owned by another pod is fetched
|
||||
// transparently), deep-copies the grain, merges the PDP request items
|
||||
// into the copy (replace-by-sku semantic so the PDP qty wins for any
|
||||
// product already in the cart), and runs the canonical
|
||||
// promotions.PromotionService.EvaluateAndApply pipeline the live cart
|
||||
// processor uses — so the preview sees the cart's vouchers, customer
|
||||
// segment, customer-lifetime-value, and computed total, not just the
|
||||
// line list, and the coupon+promotion-overlap edge case is handled
|
||||
// the same way on both sides. Returns the same EvaluateResponse shape
|
||||
// as the stateless endpoint.
|
||||
//
|
||||
// Why this exists: the storefront's "I kundkorgen" preview needs the
|
||||
// engine's verdict for "what would the cart look like if I add this
|
||||
// product". The cart is the source of truth (it has vouchers, customer
|
||||
// segment, customer-lifetime-value, total, etc. that the engine needs),
|
||||
// and a serialised copy sent from the browser can race the live cart.
|
||||
// Letting the backend read the cookie + fetch the grain means there is
|
||||
// exactly one source of truth and no race. The grain is deep-copied via
|
||||
// JSON marshal/unmarshal so the preview never mutates the live state —
|
||||
// every read is purely a what-if computation.
|
||||
//
|
||||
// Missing/invalid cookie (new visitor, preview-only tab): the handler
|
||||
// treats the request as a pure stateless evaluation of the request
|
||||
// items alone — the "what would I get just for the product I'm
|
||||
// viewing" answer is still useful on its own.
|
||||
//
|
||||
// Cart fetch failure (the grain is gone, the pool is degraded, etc.):
|
||||
// the handler logs and falls through to the same stateless evaluation
|
||||
// of the request items. The preview degrades gracefully rather than
|
||||
// erroring the whole block.
|
||||
//
|
||||
// "Replace" merge semantic: if the same sku is in both the cart and
|
||||
// the request items, the request item wins (with its qty). This
|
||||
// matches the PDP's intent ("I am previewing adding this product
|
||||
// at this qty") and avoids double-counting the same product.
|
||||
func newPromotionEvaluateWithCartHandler(store *promotions.Store, svc *promotions.PromotionService, server *PoolServer) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req promotions.EvaluateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var resp promotions.EvaluateResponse
|
||||
if g := cloneCartForPreview(server, r); g != nil {
|
||||
mergeRequestItemsIntoGrain(g, req.Items, svc.DefaultTaxProvider)
|
||||
// One call to the canonical pipeline — same code path
|
||||
// the live cart processor's reg.RegisterProcessor in
|
||||
// main.go uses. Going through the shared
|
||||
// PromotionService.EvaluateAndApply means the bypass
|
||||
// loop, the re-eval trigger, and ApplyResults stay
|
||||
// in lockstep with post-add behavior.
|
||||
svc.EvaluateAndApply(store.Snapshot(), g, previewContextOptions(req)...)
|
||||
resp = promotions.EvaluateResponse{
|
||||
TotalPrice: g.TotalPrice,
|
||||
TotalDiscount: g.TotalDiscount,
|
||||
AppliedPromotions: g.AppliedPromotions,
|
||||
Items: promotions.MapEvaluatedItems(g),
|
||||
}
|
||||
} else {
|
||||
// No live cart (missing/invalid cookie, fetch failed, or
|
||||
// deep-copy failed). Fall back to the pure stateless
|
||||
// evaluation of the request items alone — the user still
|
||||
// gets a useful preview of "what would the engine do for
|
||||
// just the items I'm about to add".
|
||||
resp = svc.Evaluate(store.Snapshot(), req)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cloneCartForPreview reads the cartid cookie, fetches the live grain
|
||||
// (cross-pod via GetAnywhere), and returns a deep copy safe to mutate
|
||||
// for the what-if preview. Returns nil (and logs) when the cookie is
|
||||
// missing/invalid OR the grain fetch fails OR the deep-copy fails —
|
||||
// the caller falls back to a stateless evaluation of the request items
|
||||
// alone.
|
||||
//
|
||||
// The deep copy is via JSON marshal/unmarshal, which is intentional: it
|
||||
// produces a fully independent *cart.CartGrain (re-allocating every
|
||||
// pointer — Items, Vouchers, ItemMeta, Price, etc.) so the live grain
|
||||
// is never touched, even if the live grain holds non-serializable
|
||||
// unexported state like an in-memory event-log channel. Unexported
|
||||
// fields are silently dropped by the json package, which is exactly
|
||||
// what we want here — the engine only reads the grain's exported
|
||||
// state (Items, Vouchers, TotalPrice, etc.), and the live grain's
|
||||
// in-memory event log / channels must not be cloned or shared.
|
||||
func cloneCartForPreview(server *PoolServer, r *http.Request) *cart.CartGrain {
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil
|
||||
}
|
||||
id, ok := cart.ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
log.Printf("promotions/evaluate-with-cart: invalid cartid cookie, falling back to stateless")
|
||||
return nil
|
||||
}
|
||||
live, err := server.GetAnywhere(r.Context(), id)
|
||||
if err != nil {
|
||||
log.Printf("promotions/evaluate-with-cart: cart fetch failed for %s, falling back to stateless: %v", id, err)
|
||||
return nil
|
||||
}
|
||||
if live == nil {
|
||||
return nil
|
||||
}
|
||||
buf, err := json.Marshal(live)
|
||||
if err != nil {
|
||||
log.Printf("promotions/evaluate-with-cart: cart marshal failed for %s, falling back to stateless: %v", id, err)
|
||||
return nil
|
||||
}
|
||||
var clone *cart.CartGrain
|
||||
if err := json.Unmarshal(buf, &clone); err != nil {
|
||||
log.Printf("promotions/evaluate-with-cart: cart unmarshal failed for %s, falling back to stateless: %v", id, err)
|
||||
return nil
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// mergeRequestItemsIntoGrain merges reqItems into the grain's items with
|
||||
// "replace" semantic: any cart line whose sku also appears in the
|
||||
// request is dropped (the request item wins, so the PDP qty replaces
|
||||
// the cart qty for that product). Remaining cart lines are kept
|
||||
// verbatim, then the converted request items are appended. The grain
|
||||
// is mutated in place — caller is expected to be holding a deep copy
|
||||
// from cloneCartForPreview. The EvalItem→CartItem conversion uses the
|
||||
// shared promotions.EvalItem.ToCartItem helper so the math (VAT rate
|
||||
// rounding, qty default, ItemMeta, Price) matches the synthetic-cart
|
||||
// conversion in the stateless endpoint exactly.
|
||||
func mergeRequestItemsIntoGrain(g *cart.CartGrain, reqItems []promotions.EvalItem, tp cart.TaxProvider) {
|
||||
requestSkus := make(map[string]struct{}, len(reqItems))
|
||||
for _, it := range reqItems {
|
||||
if it.Sku != "" {
|
||||
requestSkus[it.Sku] = struct{}{}
|
||||
}
|
||||
}
|
||||
merged := make([]*cart.CartItem, 0, len(g.Items)+len(reqItems))
|
||||
for _, it := range g.Items {
|
||||
if _, taken := requestSkus[it.Sku]; taken {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, it)
|
||||
}
|
||||
for _, it := range reqItems {
|
||||
merged = append(merged, it.ToCartItem(tp))
|
||||
}
|
||||
g.Items = merged
|
||||
}
|
||||
|
||||
// previewContextOptions builds the ContextOption list for the
|
||||
// cart-aware preview. The shared EvaluateRequest.ContextOptions
|
||||
// already includes WithNow when req.Now is set, so we only prepend a
|
||||
// default WithNow(time.Now()) when the request didn't specify one —
|
||||
// avoiding a duplicate WithNow in the opts list (the engine's
|
||||
// NewContextFromCart applies options in order, but the last-one-wins
|
||||
// rule for duplicates is not a contract we want to rely on). The
|
||||
// other context options (CustomerSegment, CLV, OrderCount) come from
|
||||
// req.ContextOptions unchanged.
|
||||
func previewContextOptions(req promotions.EvaluateRequest) []promotions.ContextOption {
|
||||
opts := req.ContextOptions()
|
||||
if req.Now == nil {
|
||||
opts = append([]promotions.ContextOption{promotions.WithNow(time.Now())}, opts...)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user