mcp in cart
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// ----------------------------
|
||||
// Action application
|
||||
// ----------------------------
|
||||
//
|
||||
// The evaluation engine (eval.go) decides *which* actions apply to a cart but
|
||||
// deliberately does not mutate the cart. Applying an action — turning a
|
||||
// "14% tiered discount" into actual öre off the total — happens here, because
|
||||
// it needs to touch cart.Price math. main.go calls ApplyActions after
|
||||
// UpdateTotals so the base totals (line items + vouchers) are settled before
|
||||
// promotions reduce them.
|
||||
|
||||
// DiscountTier describes one band of a tiered (volume) discount. Bounds are the
|
||||
// cart total *including VAT*, in öre (the cart's native unit), matching
|
||||
// PromotionEvalContext.CartTotalIncVat. MaxTotal == 0 means "no upper bound".
|
||||
//
|
||||
// A tier matches when MinTotal <= cartTotal && (MaxTotal == 0 || cartTotal <= MaxTotal).
|
||||
// Bounds are allowed to touch (e.g. 40000 ends one tier and begins the next);
|
||||
// when a total lands exactly on a shared boundary the higher tier wins, since
|
||||
// selectTier keeps the last match while scanning in order.
|
||||
type DiscountTier struct {
|
||||
MinTotal int64
|
||||
MaxTotal int64
|
||||
Percent float64
|
||||
}
|
||||
|
||||
// ApplyActions applies the matched promotion actions to the cart grain,
|
||||
// reducing TotalPrice and growing TotalDiscount. It is safe to call with a nil
|
||||
// or empty action slice. Must be called after g.UpdateTotals().
|
||||
func ApplyActions(g *cart.CartGrain, actions []Action) {
|
||||
if g == nil || g.TotalPrice == nil {
|
||||
return
|
||||
}
|
||||
for _, a := range actions {
|
||||
switch a.Type {
|
||||
case ActionTieredDiscount:
|
||||
applyTieredDiscount(g, a)
|
||||
case ActionPercentageDiscount:
|
||||
applyPercentageDiscount(g, percentFromValue(a.Value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyTieredDiscount(g *cart.CartGrain, a Action) {
|
||||
tiers := parseTiers(a.Config)
|
||||
if len(tiers) == 0 {
|
||||
return
|
||||
}
|
||||
pct, ok := selectTier(tiers, g.TotalPrice.IncVat)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
applyPercentageDiscount(g, pct)
|
||||
}
|
||||
|
||||
// applyPercentageDiscount removes pct% of the current cart total, preserving the
|
||||
// VAT-rate breakdown proportionally, and records it in TotalDiscount.
|
||||
func applyPercentageDiscount(g *cart.CartGrain, pct float64) {
|
||||
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
||||
return
|
||||
}
|
||||
discount := scalePrice(g.TotalPrice, pct)
|
||||
if discount.IncVat <= 0 {
|
||||
return
|
||||
}
|
||||
// Never discount below zero.
|
||||
if discount.IncVat > g.TotalPrice.IncVat {
|
||||
discount = g.TotalPrice
|
||||
}
|
||||
g.TotalDiscount.Add(*discount)
|
||||
g.TotalPrice.Subtract(*discount)
|
||||
}
|
||||
|
||||
// selectTier returns the discount percent for the band the total falls into.
|
||||
// Scans in declared order and keeps the last match so shared boundaries favour
|
||||
// the higher tier.
|
||||
func selectTier(tiers []DiscountTier, total int64) (float64, bool) {
|
||||
pct := 0.0
|
||||
found := false
|
||||
for _, t := range tiers {
|
||||
if total >= t.MinTotal && (t.MaxTotal == 0 || total <= t.MaxTotal) {
|
||||
pct = t.Percent
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return pct, found
|
||||
}
|
||||
|
||||
// scalePrice returns pct% of p, rounding each amount and the VAT breakdown
|
||||
// independently so the parts stay consistent with the reported total.
|
||||
func scalePrice(p *cart.Price, pct float64) *cart.Price {
|
||||
out := cart.NewPrice()
|
||||
out.IncVat = scale(p.IncVat, pct)
|
||||
for rate, amount := range p.VatRates {
|
||||
out.VatRates[rate] = scale(amount, pct)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scale(v int64, pct float64) int64 {
|
||||
return int64(math.Round(float64(v) * pct / 100.0))
|
||||
}
|
||||
|
||||
// parseTiers reads the "tiers" array out of an Action.Config. Because configs
|
||||
// are decoded via encoding/json into map[string]interface{}, every element is a
|
||||
// map[string]interface{} and every number is a float64. Accepts both
|
||||
// minTotal/maxTotal/percent and the snake_case min_total/max_total/discount_percent.
|
||||
func parseTiers(config map[string]interface{}) []DiscountTier {
|
||||
raw, ok := config["tiers"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tiers := make([]DiscountTier, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
m, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tiers = append(tiers, DiscountTier{
|
||||
MinTotal: int64(firstNum(m, "minTotal", "min_total")),
|
||||
MaxTotal: int64(firstNum(m, "maxTotal", "max_total")),
|
||||
Percent: firstNum(m, "percent", "discount_percent"),
|
||||
})
|
||||
}
|
||||
return tiers
|
||||
}
|
||||
|
||||
func percentFromValue(v interface{}) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNum(m map[string]interface{}, keys ...string) float64 {
|
||||
for _, k := range keys {
|
||||
if f, ok := m[k].(float64); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user