http and common
This commit is contained in:
+14
-17
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -10,6 +9,8 @@ import (
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/platform/httpclient"
|
||||
"git.k6n.net/mats/platform/httpserver"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
)
|
||||
|
||||
@@ -41,21 +42,14 @@ func main() {
|
||||
})
|
||||
|
||||
// Global CORS middleware allowing all origins and handling preflight.
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "*")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
Addr: addr,
|
||||
Handler: httpclient.CORS(
|
||||
httpclient.WithOrigin("*"),
|
||||
httpclient.WithMethods("GET, POST, PUT, PATCH, DELETE, OPTIONS"),
|
||||
httpclient.WithHeaders("Content-Type, Authorization, X-Requested-With"),
|
||||
httpclient.WithExposeHeaders("*"),
|
||||
)(mux),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
@@ -70,6 +64,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
}
|
||||
if err := app.Start(ctx, conn); err != nil {
|
||||
log.Printf("AMQP listener disabled: %v", err)
|
||||
@@ -78,7 +73,9 @@ func main() {
|
||||
}
|
||||
|
||||
log.Printf("backoffice HTTP listening on %s", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http server error: %v", err)
|
||||
if err := httpserver.RunServerWithShutdown(srv, "cart-backoffice", 10*time.Second, 5*time.Second,
|
||||
func(context.Context) error { cancel(); return nil },
|
||||
); err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-32
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"os"
|
||||
@@ -26,6 +25,7 @@ import (
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/platform/event"
|
||||
"git.k6n.net/mats/platform/httpserver"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -524,9 +524,11 @@ func main() {
|
||||
httpAddr := normalizeListenAddr(cartPort)
|
||||
debugAddr := normalizeListenAddr(config.EnvString("CART_DEBUG_PORT", "8081"))
|
||||
|
||||
// Debug mux on separate port (metrics + pprof).
|
||||
go http.ListenAndServe(debugAddr, debugMux)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: httpAddr,
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
ReadTimeout: 10 * time.Second,
|
||||
// Close idle keep-alive connections so a load test with many short-lived
|
||||
// clients doesn't pin file handles open indefinitely.
|
||||
@@ -535,38 +537,15 @@ func main() {
|
||||
Handler: otelhttp.NewHandler(mux, "/"),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
|
||||
fmt.Println("Shutting down due to signal")
|
||||
otelShutdown(context.Background())
|
||||
diskStorage.Close()
|
||||
pool.Close()
|
||||
|
||||
}()
|
||||
|
||||
srvErr := make(chan error, 1)
|
||||
go func() {
|
||||
srvErr <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
// Inventory change consumption used to live here over the bare Redis
|
||||
// `inventory_changed` channel; it is now owned by the cart-inventory
|
||||
// service, which translates quantity changes into inventory.level_changed
|
||||
// bus crossings. The cart reads exact stock synchronously when it needs it.
|
||||
// See docs/inventory-shape.md.
|
||||
|
||||
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr)
|
||||
|
||||
go http.ListenAndServe(debugAddr, debugMux)
|
||||
|
||||
select {
|
||||
case err = <-srvErr:
|
||||
// Error when starting HTTP server.
|
||||
log.Fatalf("Unable to start server: %v", err)
|
||||
case <-ctx.Done():
|
||||
// Wait for first CTRL+C.
|
||||
// Stop receiving signal notifications as soon as possible.
|
||||
stop()
|
||||
if err := httpserver.RunServerWithShutdown(srv, "cart", 15*time.Second, 5*time.Second,
|
||||
func(context.Context) error { stop(); return nil },
|
||||
func(context.Context) error { otelShutdown(context.Background()); return nil },
|
||||
func(context.Context) error { diskStorage.Close(); return nil },
|
||||
func(context.Context) error { pool.Close(); return nil },
|
||||
); err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -55,14 +55,6 @@ func getOriginalHost(r *http.Request) string {
|
||||
return r.Host
|
||||
}
|
||||
|
||||
func getClientIp(r *http.Request) string {
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
"git.k6n.net/mats/platform/httpclient"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||
@@ -233,7 +234,7 @@ func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
|
||||
siteUrl = checkoutPublicURL
|
||||
}
|
||||
return &CheckoutMeta{
|
||||
ClientIp: getClientIp(r),
|
||||
ClientIp: httpclient.ClientIP(r),
|
||||
SiteUrl: siteUrl,
|
||||
CallbackBaseUrl: checkoutCallbackBaseURL,
|
||||
Country: country,
|
||||
|
||||
+11
-21
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||
redisinv "git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/platform/httpserver"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||
@@ -293,35 +293,25 @@ func main() {
|
||||
w.Write([]byte("1.0.0"))
|
||||
})
|
||||
|
||||
// Debug mux on separate port (metrics + pprof).
|
||||
go http.ListenAndServe(":8081", debugMux)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
Handler: otelhttp.NewHandler(mux, "/"),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
fmt.Println("Shutting down due to signal")
|
||||
otelShutdown(context.Background())
|
||||
diskStorage.Close()
|
||||
pool.Close()
|
||||
}()
|
||||
|
||||
srvErr := make(chan error, 1)
|
||||
go func() {
|
||||
srvErr <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
log.Print("Checkout server started at port 8080")
|
||||
|
||||
go http.ListenAndServe(":8081", debugMux)
|
||||
|
||||
select {
|
||||
case err = <-srvErr:
|
||||
log.Fatalf("Unable to start server: %v", err)
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
if err := httpserver.RunServerWithShutdown(srv, "checkout", 15*time.Second, 5*time.Second,
|
||||
func(context.Context) error { stop(); return nil },
|
||||
func(context.Context) error { otelShutdown(context.Background()); return nil },
|
||||
func(context.Context) error { diskStorage.Close(); return nil },
|
||||
func(context.Context) error { pool.Close(); return nil },
|
||||
); err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,14 +36,6 @@ func getScheme(r *http.Request) string {
|
||||
return "https"
|
||||
}
|
||||
|
||||
func getClientIp(r *http.Request) string {
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func getCurrency(country string) string {
|
||||
if country == "no" {
|
||||
return "NOK"
|
||||
|
||||
+11
-22
@@ -4,9 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
@@ -22,6 +20,7 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/platform/httpserver"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
@@ -311,35 +310,25 @@ func main() {
|
||||
w.Write([]byte("1.0.0"))
|
||||
})
|
||||
|
||||
// Debug mux on separate port (metrics + pprof).
|
||||
go http.ListenAndServe(":8081", debugMux)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: config.EnvString("PROFILE_ADDR", ":8080"),
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
Handler: otelhttp.NewHandler(mux, "/"),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
fmt.Println("Shutting down profile service")
|
||||
otelShutdown(context.Background())
|
||||
diskStorage.Close()
|
||||
pool.Close()
|
||||
}()
|
||||
|
||||
srvErr := make(chan error, 1)
|
||||
go func() {
|
||||
srvErr <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
log.Print("Profile server started at port 8080")
|
||||
|
||||
go http.ListenAndServe(":8081", debugMux)
|
||||
|
||||
select {
|
||||
case err = <-srvErr:
|
||||
log.Fatalf("Unable to start server: %v", err)
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
if err := httpserver.RunServerWithShutdown(srv, "profile", 15*time.Second, 5*time.Second,
|
||||
func(context.Context) error { stop(); return nil },
|
||||
func(context.Context) error { otelShutdown(context.Background()); return nil },
|
||||
func(context.Context) error { diskStorage.Close(); return nil },
|
||||
func(context.Context) error { pool.Close(); return nil },
|
||||
); err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+155
-139
@@ -25,151 +25,167 @@ func NewPublic(store *promotions.Store, eval *promotions.PromotionService) *Serv
|
||||
|
||||
func (s *Server) buildPublicTools() []pmcp.Tool {
|
||||
return []pmcp.Tool{
|
||||
{
|
||||
Name: "list_promotions",
|
||||
Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).",
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
|
||||
}, nil),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := s.store.List()
|
||||
if a.Status != "" {
|
||||
filtered := rules[:0:0]
|
||||
for _, r := range rules {
|
||||
if string(r.Status) == a.Status {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
rules = filtered
|
||||
}
|
||||
return map[string]any{"promotions": rules, "count": len(rules)}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_promotion",
|
||||
Description: "Fetch a single promotion rule by id.",
|
||||
InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, ok := s.store.Get(a.ID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("promotion %q not found", a.ID)
|
||||
}
|
||||
return r, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "preview_cart_discount",
|
||||
Description: "Evaluate the current active promotions against a hypothetical cart total and report the " +
|
||||
"matched discount. cartTotalIncVat is in öre incl. VAT (e.g. 5 000 000 = 50 000 kr). Useful for " +
|
||||
"verifying Volymrabatt tiers without touching a real cart.",
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||
"customerSegment": pmcp.String("optional customer segment"),
|
||||
}, []string{"cartTotalIncVat"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
||||
ItemQuantity int `json:"itemQuantity"`
|
||||
CustomerSegment string `json:"customerSegment"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||
if a.CustomerSegment != "" {
|
||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||
}
|
||||
ctx := promotions.NewContextFromCart(g, opts...)
|
||||
results, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx)
|
||||
s.eval.ApplyResults(g, results, ctx)
|
||||
return map[string]any{
|
||||
"subtotalIncVat": a.CartTotalIncVat,
|
||||
"discountIncVat": g.TotalDiscount.IncVat,
|
||||
"netIncVat": g.TotalPrice.IncVat,
|
||||
"matchedActions": actions,
|
||||
"appliedPromotions": g.AppliedPromotions,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "evaluate_promotion",
|
||||
Description: "Evaluate promotions against a hypothetical cart and return the full evaluation result: " +
|
||||
"which promotions match, what discount they apply, and any pending next-tier nudges (\"spend X more for Y%\"). " +
|
||||
"When promotionId is set, only that specific promotion is evaluated (useful for testing a new rule). " +
|
||||
"When omitted, all active promotions are evaluated. cartTotalIncVat is in öre incl. VAT.",
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||
"promotionId": pmcp.String("optional: evaluate only this promotion (by id)"),
|
||||
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||
"customerSegment": pmcp.String("optional customer segment"),
|
||||
}, []string{"cartTotalIncVat"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
||||
PromotionId string `json:"promotionId"`
|
||||
ItemQuantity int `json:"itemQuantity"`
|
||||
CustomerSegment string `json:"customerSegment"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.toolListPromotions(),
|
||||
s.toolGetPromotion(),
|
||||
s.toolPreviewCartDiscount(),
|
||||
s.toolEvaluatePromotion(),
|
||||
}
|
||||
}
|
||||
|
||||
rules := s.store.Snapshot()
|
||||
if a.PromotionId != "" {
|
||||
filtered := rules[:0:0]
|
||||
for _, r := range rules {
|
||||
if r.ID == a.PromotionId {
|
||||
filtered = append(filtered, r)
|
||||
break
|
||||
}
|
||||
func (s *Server) toolListPromotions() pmcp.Tool {
|
||||
return pmcp.Tool{
|
||||
Name: "list_promotions",
|
||||
Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).",
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
|
||||
}, nil),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := s.store.List()
|
||||
if a.Status != "" {
|
||||
filtered := rules[:0:0]
|
||||
for _, r := range rules {
|
||||
if string(r.Status) == a.Status {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, fmt.Errorf("promotion %q not found", a.PromotionId)
|
||||
}
|
||||
rules = filtered
|
||||
}
|
||||
rules = filtered
|
||||
}
|
||||
return map[string]any{"promotions": rules, "count": len(rules)}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||
if a.CustomerSegment != "" {
|
||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||
func (s *Server) toolGetPromotion() pmcp.Tool {
|
||||
return pmcp.Tool{
|
||||
Name: "get_promotion",
|
||||
Description: "Fetch a single promotion rule by id.",
|
||||
InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, ok := s.store.Get(a.ID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("promotion %q not found", a.ID)
|
||||
}
|
||||
return r, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) toolPreviewCartDiscount() pmcp.Tool {
|
||||
return pmcp.Tool{
|
||||
Name: "preview_cart_discount",
|
||||
Description: "Evaluate the current active promotions against a hypothetical cart total and report the " +
|
||||
"matched discount. cartTotalIncVat is in öre incl. VAT (e.g. 5 000 000 = 50 000 kr). Useful for " +
|
||||
"verifying Volymrabatt tiers without touching a real cart.",
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||
"customerSegment": pmcp.String("optional customer segment"),
|
||||
}, []string{"cartTotalIncVat"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
||||
ItemQuantity int `json:"itemQuantity"`
|
||||
CustomerSegment string `json:"customerSegment"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||
if a.CustomerSegment != "" {
|
||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||
}
|
||||
ctx := promotions.NewContextFromCart(g, opts...)
|
||||
results, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx)
|
||||
s.eval.ApplyResults(g, results, ctx)
|
||||
return map[string]any{
|
||||
"subtotalIncVat": a.CartTotalIncVat,
|
||||
"discountIncVat": g.TotalDiscount.IncVat,
|
||||
"netIncVat": g.TotalPrice.IncVat,
|
||||
"matchedActions": actions,
|
||||
"appliedPromotions": g.AppliedPromotions,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) toolEvaluatePromotion() pmcp.Tool {
|
||||
return pmcp.Tool{
|
||||
Name: "evaluate_promotion",
|
||||
Description: "Evaluate promotions against a hypothetical cart and return the full evaluation result: " +
|
||||
"which promotions match, what discount they apply, and any pending next-tier nudges (\"spend X more for Y%\"). " +
|
||||
"When promotionId is set, only that specific promotion is evaluated (useful for testing a new rule). " +
|
||||
"When omitted, all active promotions are evaluated. cartTotalIncVat is in öre incl. VAT.",
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||
"promotionId": pmcp.String("optional: evaluate only this promotion (by id)"),
|
||||
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||
"customerSegment": pmcp.String("optional customer segment"),
|
||||
}, []string{"cartTotalIncVat"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
||||
PromotionId string `json:"promotionId"`
|
||||
ItemQuantity int `json:"itemQuantity"`
|
||||
CustomerSegment string `json:"customerSegment"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := s.store.Snapshot()
|
||||
if a.PromotionId != "" {
|
||||
filtered := rules[:0:0]
|
||||
for _, r := range rules {
|
||||
if r.ID == a.PromotionId {
|
||||
filtered = append(filtered, r)
|
||||
break
|
||||
}
|
||||
}
|
||||
ctx := promotions.NewContextFromCart(g, opts...)
|
||||
results, actions := s.eval.EvaluateAll(rules, ctx)
|
||||
evalResults := make([]map[string]any, 0, len(results))
|
||||
for _, r := range results {
|
||||
evalResults = append(evalResults, map[string]any{
|
||||
"ruleId": r.Rule.ID,
|
||||
"name": r.Rule.Name,
|
||||
"applicable": r.Applicable,
|
||||
"failedReason": r.FailedReason,
|
||||
"matchedActions": r.MatchedActions,
|
||||
})
|
||||
if len(filtered) == 0 {
|
||||
return nil, fmt.Errorf("promotion %q not found", a.PromotionId)
|
||||
}
|
||||
s.eval.ApplyResults(g, results, ctx)
|
||||
return map[string]any{
|
||||
"subtotalIncVat": a.CartTotalIncVat,
|
||||
"discountIncVat": g.TotalDiscount.IncVat,
|
||||
"netIncVat": g.TotalPrice.IncVat,
|
||||
"matchedActions": actions,
|
||||
"appliedPromotions": g.AppliedPromotions,
|
||||
"evaluationResults": evalResults,
|
||||
}, nil
|
||||
},
|
||||
rules = filtered
|
||||
}
|
||||
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||
if a.CustomerSegment != "" {
|
||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||
}
|
||||
ctx := promotions.NewContextFromCart(g, opts...)
|
||||
results, actions := s.eval.EvaluateAll(rules, ctx)
|
||||
evalResults := make([]map[string]any, 0, len(results))
|
||||
for _, r := range results {
|
||||
evalResults = append(evalResults, map[string]any{
|
||||
"ruleId": r.Rule.ID,
|
||||
"name": r.Rule.Name,
|
||||
"applicable": r.Applicable,
|
||||
"failedReason": r.FailedReason,
|
||||
"matchedActions": r.MatchedActions,
|
||||
})
|
||||
}
|
||||
s.eval.ApplyResults(g, results, ctx)
|
||||
return map[string]any{
|
||||
"subtotalIncVat": a.CartTotalIncVat,
|
||||
"discountIncVat": g.TotalDiscount.IncVat,
|
||||
"netIncVat": g.TotalPrice.IncVat,
|
||||
"matchedActions": actions,
|
||||
"appliedPromotions": g.AppliedPromotions,
|
||||
"evaluationResults": evalResults,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,50 +16,8 @@ import (
|
||||
// and the handler that runs it.
|
||||
func (s *Server) buildTools() []pmcp.Tool {
|
||||
return []pmcp.Tool{
|
||||
{
|
||||
Name: "list_promotions",
|
||||
Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).",
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
|
||||
}, nil),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := s.store.List()
|
||||
if a.Status != "" {
|
||||
filtered := rules[:0:0]
|
||||
for _, r := range rules {
|
||||
if string(r.Status) == a.Status {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
rules = filtered
|
||||
}
|
||||
return map[string]any{"promotions": rules, "count": len(rules)}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_promotion",
|
||||
Description: "Fetch a single promotion rule by id.",
|
||||
InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, ok := s.store.Get(a.ID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("promotion %q not found", a.ID)
|
||||
}
|
||||
return r, nil
|
||||
},
|
||||
},
|
||||
s.toolListPromotions(),
|
||||
s.toolGetPromotion(),
|
||||
{
|
||||
Name: "upsert_promotion",
|
||||
Description: "Create a promotion or replace the existing one with the same id. " +
|
||||
@@ -142,42 +100,7 @@ func (s *Server) buildTools() []pmcp.Tool {
|
||||
return map[string]any{"id": a.ID, "deleted": true}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "preview_cart_discount",
|
||||
Description: "Evaluate the current active promotions against a hypothetical cart total and report the " +
|
||||
"matched discount. cartTotalIncVat is in öre incl. VAT (e.g. 5 000 000 = 50 000 kr). Useful for " +
|
||||
"verifying Volymrabatt tiers without touching a real cart.",
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||
"customerSegment": pmcp.String("optional customer segment"),
|
||||
}, []string{"cartTotalIncVat"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
||||
ItemQuantity int `json:"itemQuantity"`
|
||||
CustomerSegment string `json:"customerSegment"`
|
||||
}
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||
if a.CustomerSegment != "" {
|
||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||
}
|
||||
ctx := promotions.NewContextFromCart(g, opts...)
|
||||
results, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx)
|
||||
s.eval.ApplyResults(g, results, ctx)
|
||||
return map[string]any{
|
||||
"subtotalIncVat": a.CartTotalIncVat,
|
||||
"discountIncVat": g.TotalDiscount.IncVat,
|
||||
"netIncVat": g.TotalPrice.IncVat,
|
||||
"matchedActions": actions,
|
||||
"appliedPromotions": g.AppliedPromotions,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
s.toolPreviewCartDiscount(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user