parent item mutations
This commit is contained in:
@@ -120,6 +120,14 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
// Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total,
|
||||
// cart_mutations_total, cart_remote_negotiation_total,
|
||||
// cart_connected_remotes, …) are bumped centrally by SimpleGrainPool
|
||||
// — see pkg/actor/simple_grain_pool.go. The cart Metrics is
|
||||
// registered once in cmd/cart/main.go and shared with DiskStorage
|
||||
// via SetMetrics, so the per-handler counters this file used to
|
||||
// maintain are no longer needed.
|
||||
|
||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// TestWriteResult_CartGrainCarriesEvaluatedItems verifies that the per-line
|
||||
// promotion breakdown lives on CartGrain.EvaluatedItems (so the canonical
|
||||
// promotion pipeline populates it once per mutation, not at every read
|
||||
// path) and naturally serialises through WriteResult alongside the rest
|
||||
// of the grain's tagged fields. There is no envelope wrapper anymore —
|
||||
// the grain's own JSON tag carries the field. See pkg/cart/evaluated_item.go
|
||||
// for EvaluatedItem semantics and pkg/cart/cart-grain.go for the EvaluatedItems
|
||||
// field declaration.
|
||||
func TestWriteResult_CartGrainCarriesEvaluatedItems(t *testing.T) {
|
||||
grain := &cart.CartGrain{
|
||||
Id: 1,
|
||||
Currency: "SEK",
|
||||
Items: []*cart.CartItem{
|
||||
{
|
||||
Sku: "promo-1",
|
||||
Quantity: 2,
|
||||
Price: *cart.NewPriceFromIncVat(10000, 2500),
|
||||
Discount: cart.NewPriceFromIncVat(2000, 500),
|
||||
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||
},
|
||||
},
|
||||
TotalPrice: cart.NewPriceFromIncVat(20000, 5000),
|
||||
TotalDiscount: cart.NewPriceFromIncVat(4000, 1000),
|
||||
EvaluatedItems: []cart.EvaluatedItem{{
|
||||
Sku: "promo-1",
|
||||
Name: "Promo Item",
|
||||
Quantity: 2,
|
||||
PriceIncVat: 10000,
|
||||
OrgPriceIncVat: 12000,
|
||||
DiscountIncVat: 2000,
|
||||
EffectivePriceIncVat: 9000,
|
||||
EffectiveTotalIncVat: 18000,
|
||||
}},
|
||||
}
|
||||
|
||||
s := &PoolServer{}
|
||||
w := httptest.NewRecorder()
|
||||
if err := s.WriteResult(w, grain); err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v (body=%q)", err, w.Body.String())
|
||||
}
|
||||
|
||||
for _, key := range []string{"id", "currency", "items", "totalPrice", "totalDiscount", "evaluatedItems"} {
|
||||
if _, ok := resp[key]; !ok {
|
||||
t.Fatalf("expected top-level %q in grain serialisation, got keys %v", key, mapKeys(resp))
|
||||
}
|
||||
}
|
||||
|
||||
var ev []struct {
|
||||
Sku string `json:"sku"`
|
||||
DiscountIncVat int64 `json:"discountIncVat"`
|
||||
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"`
|
||||
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"`
|
||||
}
|
||||
if err := json.Unmarshal(resp["evaluatedItems"], &ev); err != nil {
|
||||
t.Fatalf("evaluatedItems parse: %v", err)
|
||||
}
|
||||
if len(ev) != 1 || ev[0].Sku != "promo-1" {
|
||||
t.Fatalf("expected one promo-1 evaluated item, got %+v", ev)
|
||||
}
|
||||
if ev[0].DiscountIncVat != 2000 || ev[0].EffectiveTotalIncVat != 18000 || ev[0].EffectivePriceIncVat != 9000 {
|
||||
t.Fatalf("evaluatedItem math off: %+v", ev[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteResult_MutationResultNestsEvaluatedItems verifies the natural
|
||||
// post-mutation wire shape: actor.MutationResult[cart.CartGrain] serialises
|
||||
// Result (the grain) and Mutations (applied-change metadata) at the existing
|
||||
// top-level positions, and CartGrain's EvaluatedItems rides inside Result
|
||||
// because the grain owns the field. Previously the envelope wrapper pulled
|
||||
// evaluatedItems out as a sibling of {result, mutations}; the natural
|
||||
// nesting is what the user wanted ("no strange patterns on a global level").
|
||||
// Internal/admin tools that read response.evaluatedItems will need to read
|
||||
// response.result.evaluatedItems instead. Wrap-around breakage acknowledged.
|
||||
func TestWriteResult_MutationResultNestsEvaluatedItems(t *testing.T) {
|
||||
grain := &cart.CartGrain{
|
||||
Id: 1,
|
||||
Currency: "SEK",
|
||||
Items: []*cart.CartItem{
|
||||
{
|
||||
Sku: "promo-1",
|
||||
Quantity: 1,
|
||||
Price: *cart.NewPriceFromIncVat(10000, 2500),
|
||||
Discount: cart.NewPriceFromIncVat(3000, 750),
|
||||
},
|
||||
},
|
||||
EvaluatedItems: []cart.EvaluatedItem{{
|
||||
Sku: "promo-1",
|
||||
Quantity: 1,
|
||||
PriceIncVat: 10000,
|
||||
DiscountIncVat: 3000,
|
||||
EffectiveTotalIncVat: 7000,
|
||||
EffectivePriceIncVat: 7000,
|
||||
}},
|
||||
}
|
||||
|
||||
mr := &actor.MutationResult[cart.CartGrain]{
|
||||
Result: *grain,
|
||||
Mutations: []actor.ApplyResult{{Type: "cart.AddItem"}},
|
||||
}
|
||||
|
||||
s := &PoolServer{}
|
||||
w := httptest.NewRecorder()
|
||||
if err := s.WriteResult(w, mr); err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// {result, mutations} are the existing envelope — preserved.
|
||||
for _, key := range []string{"result", "mutations"} {
|
||||
if _, ok := resp[key]; !ok {
|
||||
t.Fatalf("expected top-level %q in mutation response, got %v", key, mapKeys(resp))
|
||||
}
|
||||
}
|
||||
// evaluatedItems no longer rises to the top level (the envelope is
|
||||
// gone). Confirm absence at the top — it's now nested under result.
|
||||
if _, hasTop := resp["evaluatedItems"]; hasTop {
|
||||
t.Fatalf("evaluatedItems leaked to top level — envelope wrapper returned somehow")
|
||||
}
|
||||
|
||||
// Result nests the grain, including EvaluatedItems naturally.
|
||||
var inner map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp["result"], &inner); err != nil {
|
||||
t.Fatalf("result parse: %v", err)
|
||||
}
|
||||
if _, ok := inner["items"]; !ok {
|
||||
t.Fatalf("result.items missing — Result did not carry the grain")
|
||||
}
|
||||
evRaw, ok := inner["evaluatedItems"]
|
||||
if !ok {
|
||||
t.Fatalf("result.evaluatedItems missing — grain field should nest under result")
|
||||
}
|
||||
var ev []struct {
|
||||
Sku string `json:"sku"`
|
||||
DiscountIncVat int64 `json:"discountIncVat"`
|
||||
}
|
||||
if err := json.Unmarshal(evRaw, &ev); err != nil {
|
||||
t.Fatalf("result.evaluatedItems parse: %v", err)
|
||||
}
|
||||
if len(ev) != 1 || ev[0].Sku != "promo-1" || ev[0].DiscountIncVat != 3000 {
|
||||
t.Fatalf("result.evaluatedItems wrong: %+v", ev)
|
||||
}
|
||||
|
||||
// Mutations are still a sibling of result.
|
||||
var muts []map[string]any
|
||||
if err := json.Unmarshal(resp["mutations"], &muts); err != nil {
|
||||
t.Fatalf("mutations parse: %v", err)
|
||||
}
|
||||
if len(muts) != 1 || muts[0]["type"] != "cart.AddItem" {
|
||||
t.Fatalf("mutations wrong: %+v", muts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteResult_NoEvaluatedItemsOmitsField verifies the EvaluatedItems
|
||||
// omitempty on CartGrain drops the field entirely when the canonical
|
||||
// pipeline hasn't run (synthetic grain in a fixture, fresh spawn before
|
||||
// any promotion-aware mutation). Clients reading evaluatedItems can
|
||||
// rely on absence to mean "no promotion compute yet".
|
||||
func TestWriteResult_NoEvaluatedItemsOmitsField(t *testing.T) {
|
||||
grain := &cart.CartGrain{
|
||||
Id: 1,
|
||||
Currency: "SEK",
|
||||
}
|
||||
|
||||
s := &PoolServer{}
|
||||
w := httptest.NewRecorder()
|
||||
if err := s.WriteResult(w, grain); err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, has := resp["evaluatedItems"]; has {
|
||||
t.Fatalf("expected evaluatedItems omitted on a grain with nil breakdown")
|
||||
}
|
||||
}
|
||||
|
||||
// mapKeys returns the sorted set of top-level keys for nicer failure output.
|
||||
func mapKeys(m map[string]json.RawMessage) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -86,13 +86,17 @@ func newPromotionEvaluateWithCartHandler(store *promotions.Store, svc *promotion
|
||||
// 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.
|
||||
// in lockstep with post-add behavior. EvaluateAndApply
|
||||
// populates g.EvaluatedItems via cart.MapEvaluatedItems
|
||||
// on its final step, so the preview response reuses the
|
||||
// same per-line projection the live cart renders — math
|
||||
// and rounding stay in lockstep across both paths.
|
||||
svc.EvaluateAndApply(store.Snapshot(), g, previewContextOptions(req)...)
|
||||
resp = promotions.EvaluateResponse{
|
||||
TotalPrice: g.TotalPrice,
|
||||
TotalDiscount: g.TotalDiscount,
|
||||
AppliedPromotions: g.AppliedPromotions,
|
||||
Items: promotions.MapEvaluatedItems(g),
|
||||
Items: g.EvaluatedItems,
|
||||
}
|
||||
} else {
|
||||
// No live cart (missing/invalid cookie, fetch failed, or
|
||||
|
||||
@@ -195,6 +195,16 @@ func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse {
|
||||
resp.Status = string(*g.CheckoutStatus)
|
||||
}
|
||||
|
||||
// Per-line evaluation breakdown. The canonical promotion pipeline
|
||||
// (pkg/promotions.PromotionService.EvaluateAndApply) populates this
|
||||
// field on the grain itself after every successful mutation, so the
|
||||
// conversion just reads g.EvaluatedItems verbatim — no recompute at
|
||||
// response time. Same shape /promotions/evaluate-with-cart returns
|
||||
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems),
|
||||
// so a line verified via GET /ucp/v1/carts/{id} byte-matches the
|
||||
// preview from /promotions/evaluate-with-cart.
|
||||
resp.EvaluatedItems = g.EvaluatedItems
|
||||
|
||||
for _, it := range g.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
|
||||
@@ -229,6 +229,106 @@ func TestCartResponse_Conversion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCartResponse_EvaluatedItems verifies that the per-line breakdown the
|
||||
// canonical promotion pipeline populates on CartGrain.EvaluatedItems flows
|
||||
// verbatim into the UCP CartResponse's EvaluatedItems field. Conversion is a
|
||||
// straight pass-through now — we hand-construct the grain's EvaluatedItems
|
||||
// field with the expected values to lock down the wire shape independent of
|
||||
// the math MapEvaluatedItems applies (which has its own tests in pkg/cart).
|
||||
func TestCartResponse_EvaluatedItems(t *testing.T) {
|
||||
id := mustParseID("ABCD")
|
||||
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
|
||||
g.Currency = "SEK"
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Sku: "promo-1",
|
||||
Quantity: 2,
|
||||
Price: *mustPrice(10000, 2500),
|
||||
TotalPrice: *mustPrice(20000, 5000),
|
||||
Tax: 2500,
|
||||
OrgPrice: mustPrice(12000, 3000),
|
||||
Discount: mustPrice(2000, 500),
|
||||
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||
})
|
||||
g.TotalPrice = mustPrice(20000, 5000)
|
||||
|
||||
// In the live cart, pkg/promotions.PromotionService.EvaluateAndApply
|
||||
// populates g.EvaluatedItems via cart.MapEvaluatedItems after every
|
||||
// successful mutation. The conversion test only cares that whatever
|
||||
// the grain carries projects into resp.EvaluatedItems unchanged — so
|
||||
// the input is the canonical shape, not the math's output.
|
||||
g.EvaluatedItems = []cart.EvaluatedItem{{
|
||||
Sku: "promo-1",
|
||||
Name: "Promo Item",
|
||||
Quantity: 2,
|
||||
PriceIncVat: 10000,
|
||||
OrgPriceIncVat: 12000,
|
||||
DiscountIncVat: 2000,
|
||||
EffectivePriceIncVat: 9000,
|
||||
EffectiveTotalIncVat: 18000,
|
||||
}}
|
||||
|
||||
resp := cartGrainToResponse(g.Id.String(), g)
|
||||
if len(resp.EvaluatedItems) != 1 {
|
||||
t.Fatalf("expected 1 evaluated item, got %d", len(resp.EvaluatedItems))
|
||||
}
|
||||
ev := resp.EvaluatedItems[0]
|
||||
if ev.Sku != "promo-1" {
|
||||
t.Fatalf("expected sku 'promo-1', got %q", ev.Sku)
|
||||
}
|
||||
if ev.Name != "Promo Item" {
|
||||
t.Fatalf("expected name 'Promo Item', got %q", ev.Name)
|
||||
}
|
||||
if ev.Quantity != 2 {
|
||||
t.Fatalf("expected qty 2, got %d", ev.Quantity)
|
||||
}
|
||||
if ev.PriceIncVat != 10000 {
|
||||
t.Fatalf("expected priceIncVat 10000, got %d", ev.PriceIncVat)
|
||||
}
|
||||
if ev.OrgPriceIncVat != 12000 {
|
||||
t.Fatalf("expected orgPriceIncVat 12000, got %d", ev.OrgPriceIncVat)
|
||||
}
|
||||
if ev.DiscountIncVat != 2000 {
|
||||
t.Fatalf("expected discountIncVat 2000, got %d", ev.DiscountIncVat)
|
||||
}
|
||||
if ev.EffectiveTotalIncVat != 18000 {
|
||||
t.Fatalf("expected effectiveTotalIncVat 18000, got %d", ev.EffectiveTotalIncVat)
|
||||
}
|
||||
if ev.EffectivePriceIncVat != 9000 {
|
||||
t.Fatalf("expected effectivePriceIncVat 9000, got %d", ev.EffectivePriceIncVat)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCartResponse_EvaluatedItems_MapEquivalence confirms that
|
||||
// cart.MapEvaluatedItems produces the same shape the test's hand-built
|
||||
// []cart.EvaluatedItem entries contain, given matching inputs. This locks
|
||||
// the conversion path's output to the canonical pipeline's output — if
|
||||
// either side starts omitting or renaming a field, this fails.
|
||||
func TestCartResponse_EvaluatedItems_MapEquivalence(t *testing.T) {
|
||||
id := mustParseID("ABCD")
|
||||
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
|
||||
g.Currency = "SEK"
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Sku: "promo-1",
|
||||
Quantity: 2,
|
||||
Price: *mustPrice(10000, 2500),
|
||||
TotalPrice: *mustPrice(20000, 5000),
|
||||
Tax: 2500,
|
||||
OrgPrice: mustPrice(12000, 3000),
|
||||
Discount: mustPrice(2000, 500),
|
||||
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||
})
|
||||
g.TotalPrice = mustPrice(20000, 5000)
|
||||
|
||||
canonical := cart.MapEvaluatedItems(g)
|
||||
if len(canonical) != 1 {
|
||||
t.Fatalf("expected 1 evaluated item from MapEvaluatedItems, got %d", len(canonical))
|
||||
}
|
||||
e := canonical[0]
|
||||
if e.DiscountIncVat != 2000 || e.EffectiveTotalIncVat != 18000 || e.EffectivePriceIncVat != 9000 {
|
||||
t.Fatalf("MapEvaluatedItems math off: %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func mustPrice(incVat int64, totalVat int64) *cart.Price {
|
||||
return &cart.Price{
|
||||
IncVat: money.Cents(incVat),
|
||||
|
||||
@@ -121,9 +121,24 @@ type VoucherInput struct {
|
||||
}
|
||||
|
||||
// CartResponse is the UCP cart response body.
|
||||
//
|
||||
// EvaluatedItems mirrors the per-line breakdown the canonical promotion
|
||||
// pipeline (pkg/promotions.PromotionService.EvaluateAndApply) populates
|
||||
// on the grain after every successful mutation — see
|
||||
// pkg/cart/evaluated_item.go for the EvaluatedItem type and the rounding
|
||||
// rules. The shape and field order match the live cart grain's own
|
||||
// EvaluatedItems field and the response from /promotions/evaluate-with-cart
|
||||
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems), so a
|
||||
// line verified via GET /ucp/v1/carts/{id} byte-matches the preview.
|
||||
//
|
||||
// Kept parallel to Items rather than merged into CartItem to preserve
|
||||
// the UCP wire contract — existing UCP clients keep working unchanged
|
||||
// and the per-line breakdown can grow on the EvaluatedItem type without
|
||||
// churning the standard item shape.
|
||||
type CartResponse struct {
|
||||
Id string `json:"id"`
|
||||
Items []CartItem `json:"items,omitempty"`
|
||||
EvaluatedItems []cart.EvaluatedItem `json:"evaluatedItems,omitempty"`
|
||||
Totals Totals `json:"totals"`
|
||||
Status string `json:"status"`
|
||||
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
||||
|
||||
@@ -161,6 +161,18 @@ type CartGrain struct {
|
||||
Items []*CartItem `json:"items"`
|
||||
TotalPrice *Price `json:"totalPrice"`
|
||||
TotalDiscount *Price `json:"totalDiscount"`
|
||||
// EvaluatedItems is the per-line promotion breakdown — see
|
||||
// EvaluatedItem for shape and semantics. Populated by the canonical
|
||||
// promotion pipeline (pkg/promotions.PromotionService.EvaluateAndApply)
|
||||
// after every successful mutation, alongside TotalDiscount and
|
||||
// AppliedPromotions, so any consumer of the grain (UCP cart response,
|
||||
// legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) sees the
|
||||
// per-line discount/effective totals without re-running the engine at
|
||||
// response time. Mirrors the same shape /promotions/evaluate-with-cart
|
||||
// has returned since the breakdown feature was introduced; deliberately
|
||||
// persisted via the event-log JSON block so we don't have to re-derive
|
||||
// it on every read path (same precedent as TotalPrice/TotalDiscount).
|
||||
EvaluatedItems []EvaluatedItem `json:"evaluatedItems,omitempty"`
|
||||
Processing bool `json:"processing"`
|
||||
//PaymentInProgress uint16 `json:"paymentInProgress"`
|
||||
OrderReference string `json:"orderReference,omitempty"`
|
||||
|
||||
@@ -2,9 +2,11 @@ package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/platform/inventory"
|
||||
)
|
||||
|
||||
@@ -71,6 +73,73 @@ func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sk
|
||||
return c.reservationService.Release(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
||||
}
|
||||
|
||||
// CascadeChildQuantities scales every direct child's quantity
|
||||
// proportionally when a parent's quantity is bumped (ChangeQuantity or
|
||||
// the AddItem-merge path).
|
||||
//
|
||||
// - Ratio: newChildQty = oldChildQty × newParentQty / oldParentQty.
|
||||
// - Floor: scale-down via integer division can yield 0 (e.g. parent
|
||||
// 2 → 1, child qty 1 → (1×1)/2 = 0). We clamp to 1 so a scale-down
|
||||
// never silently deletes an accessory. The mismatch is acceptable —
|
||||
// shipping one extra child is better than dropping one.
|
||||
// - Reservations: each child's reservation is released then re-acquired
|
||||
// at the new quantity if UseReservations is true for that child AND a
|
||||
// prior reservation existed. A failing reservation is logged, not
|
||||
// raised, so a single bad child doesn't abort the whole cascade
|
||||
// (matches the existing removal pattern in mutation_items.go).
|
||||
// - Recursion: descends into grandchildren using the child's own
|
||||
// before/after quantities as the next ratio's input, so deeply
|
||||
// nested accessory trees (parent → child → grandchild) stay in
|
||||
// ratio with the top-level change.
|
||||
//
|
||||
// Caller is responsible for locking any grain mutex and for re-running
|
||||
// UpdateTotals after the cascade has settled.
|
||||
func (c *CartMutationContext) CascadeChildQuantities(
|
||||
ctx context.Context,
|
||||
g *CartGrain,
|
||||
parentLineId uint32,
|
||||
oldParentQty uint16,
|
||||
newParentQty uint16,
|
||||
) {
|
||||
if oldParentQty == 0 || oldParentQty == newParentQty {
|
||||
return
|
||||
}
|
||||
|
||||
for _, it := range g.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
if it.ParentId == nil || *it.ParentId != parentLineId {
|
||||
continue
|
||||
}
|
||||
oldChildQty := it.Quantity
|
||||
ratio := uint32(newParentQty) * uint32(oldChildQty) / uint32(oldParentQty)
|
||||
newChildQty := uint16(ratio)
|
||||
if newChildQty == 0 {
|
||||
newChildQty = 1
|
||||
}
|
||||
if newChildQty == oldChildQty {
|
||||
continue
|
||||
}
|
||||
|
||||
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(it) && it.ReservationEndTime != nil {
|
||||
if err := c.ReleaseItem(ctx, g.Id, it.Sku, it.StoreId); err != nil {
|
||||
log.Printf("CascadeChildQuantities: failed to release %s: %v", it.Sku, err)
|
||||
}
|
||||
endTime, err := c.ReserveItem(ctx, g.Id, it.Sku, it.StoreId, newChildQty)
|
||||
if err != nil {
|
||||
log.Printf("CascadeChildQuantities: failed to reserve %s at qty %d: %v", it.Sku, newChildQty, err)
|
||||
} else if endTime != nil {
|
||||
it.ReservationEndTime = endTime
|
||||
}
|
||||
}
|
||||
|
||||
it.Quantity = newChildQty
|
||||
// Descend so grandchildren track this child's ratio.
|
||||
c.CascadeChildQuantities(ctx, g, it.Id, oldChildQty, newChildQty)
|
||||
}
|
||||
}
|
||||
|
||||
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
|
||||
|
||||
reg := actor.NewMutationRegistry()
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package cart
|
||||
|
||||
// EvaluatedItem is one line in the per-line promotion breakdown exposed
|
||||
// alongside a cart. It mirrors the line that was evaluated (sku,
|
||||
// quantity, unit list price, optional OrgPrice for the strikethrough) and
|
||||
// adds the per-line discount the engine applied (orgPrice-based +
|
||||
// promotion-based, additive) plus the resulting effective per-unit and
|
||||
// per-line totals.
|
||||
//
|
||||
// Lives in pkg/cart rather than pkg/promotions so CartGrain can carry the
|
||||
// breakdown as a derived field (populated by the canonical promotion
|
||||
// pipeline that already manages TotalDiscount/AppliedPromotions), and so
|
||||
// every consumer of the grain — UCP cart response, legacy /cart HTTP
|
||||
// handler, cart-mcp, AMQP mutation feed — naturally sees the same shape
|
||||
// without ad-hoc wrappers at each call site. JSON tags are the canonical
|
||||
// EvaluatedItem shape that /promotions/evaluate-with-cart has returned
|
||||
// since the breakdown feature was introduced.
|
||||
//
|
||||
// Distributing the total cart discount down to the line level lets the
|
||||
// storefront render "Item X: 100 kr → 80 kr" without re-doing the math
|
||||
// on the client and lets verifiers confirm which promotion hit which
|
||||
// line (per-line DiscountIncVat is the engine's authoritative answer).
|
||||
type EvaluatedItem struct {
|
||||
Sku string `json:"sku"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Quantity uint16 `json:"qty"`
|
||||
PriceIncVat int64 `json:"priceIncVat"` // per-unit list price
|
||||
OrgPriceIncVat int64 `json:"orgPriceIncVat,omitempty"` // per-unit pre-discount list price (for strikethrough)
|
||||
DiscountIncVat int64 `json:"discountIncVat"` // per-line TOTAL discount (orgPrice + promotion), incVat in öre
|
||||
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` // per-unit, after discount, incVat in öre (rounded)
|
||||
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` // per-line, after discount, incVat in öre (exact)
|
||||
}
|
||||
|
||||
// MapEvaluatedItems walks the grain's items and produces the per-line
|
||||
// EvaluatedItem list. The grain's item.Discount carries the TOTAL per-line
|
||||
// discount (orgPrice + promotion — additive, set by UpdateTotals and the
|
||||
// effects' per-line distribution). The effective totals are derived as
|
||||
// (price * qty - discount), clamped to 0 (a promotion that over-discounted
|
||||
// a line shows as free, not negative). The effective per-unit price is the
|
||||
// per-line total divided by quantity, rounded to the nearest öre so the
|
||||
// per-unit display matches the per-line total when multiplied back.
|
||||
//
|
||||
// Returns nil for a nil grain (callers that always pass a non-nil grain
|
||||
// — the typical case — get a non-nil empty slice for an empty cart, which
|
||||
// is the desired behavior for JSON omitempty at the consumer).
|
||||
//
|
||||
// Exported so the canonical promotion pipeline (pkg/promotions.EvaluateAndApply)
|
||||
// and the manual /promotions/evaluate-with-cart preview handler can produce
|
||||
// the same per-line shape.
|
||||
func MapEvaluatedItems(g *CartGrain) []EvaluatedItem {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]EvaluatedItem, 0, len(g.Items))
|
||||
for _, it := range g.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
var name string
|
||||
if it.Meta != nil {
|
||||
name = it.Meta.Name
|
||||
}
|
||||
var orgPrice int64
|
||||
if it.OrgPrice != nil {
|
||||
orgPrice = it.OrgPrice.IncVat.Int64()
|
||||
}
|
||||
var discount int64
|
||||
if it.Discount != nil {
|
||||
discount = it.Discount.IncVat.Int64()
|
||||
}
|
||||
priceRow := it.Price.IncVat.Int64() * int64(it.Quantity)
|
||||
effTotal := priceRow - discount
|
||||
if effTotal < 0 {
|
||||
effTotal = 0
|
||||
}
|
||||
var effUnit int64
|
||||
if it.Quantity > 0 {
|
||||
// Nearest-öre rounding so per-unit * qty matches the
|
||||
// per-line total (within 1 öre either way). Half-up:
|
||||
// (effTotal + qty/2) / qty.
|
||||
effUnit = (effTotal + int64(it.Quantity)/2) / int64(it.Quantity)
|
||||
}
|
||||
out = append(out, EvaluatedItem{
|
||||
Sku: it.Sku,
|
||||
Name: name,
|
||||
Quantity: it.Quantity,
|
||||
PriceIncVat: it.Price.IncVat.Int64(),
|
||||
OrgPriceIncVat: orgPrice,
|
||||
DiscountIncVat: discount,
|
||||
EffectivePriceIncVat: effUnit,
|
||||
EffectiveTotalIncVat: effTotal,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -38,3 +38,33 @@ func TestAddItem_MergesByItemIdNotSku(t *testing.T) {
|
||||
t.Fatalf("items = %d, want 2", len(g.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-adding the same parent (same ItemId + StoreId, no ParentId on the
|
||||
// re-add itself) merges the quantity, and the proportional cascade resizes
|
||||
// every direct child to match the new parent qty so a "+1 drill" leaves
|
||||
// the bundle internally consistent.
|
||||
func TestAddItem_MergeCascadesToChildren(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||
t.Fatalf("add parent: %v", err)
|
||||
}
|
||||
parentLine := g.Items[0].Id
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
|
||||
t.Fatalf("add child: %v", err)
|
||||
}
|
||||
|
||||
// Re-add the same parent — same ItemId, no ParentId on the re-add itself.
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||
t.Fatalf("re-add parent: %v", err)
|
||||
}
|
||||
|
||||
// Parent qty 1 -> 2; child qty 1 -> 2 via cascade.
|
||||
for _, it := range g.Items {
|
||||
if it.Quantity != 2 {
|
||||
t.Errorf("line %s qty = %d, want 2 (merge cascade)", it.Sku, it.Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
// mutation_change_quantity.go
|
||||
@@ -15,8 +14,11 @@ import (
|
||||
//
|
||||
// Behavior:
|
||||
// - Locates an item by its cart-local line item Id (not source item_id).
|
||||
// - If requested quantity <= 0 the line is removed.
|
||||
// - Otherwise the line's Quantity field is updated.
|
||||
// - If requested quantity <= 0 the line AND any descendants are
|
||||
// removed via the shared RemoveItem cascade (transitive parent→child).
|
||||
// - Otherwise the line's Quantity is updated and direct children are
|
||||
// rescaled proportionally so a "2 of this + 2 of each accessory"
|
||||
// bundle stays internally consistent when the parent becomes 3.
|
||||
// - Totals are recalculated (WithTotals).
|
||||
//
|
||||
// Error handling:
|
||||
@@ -29,7 +31,7 @@ import (
|
||||
// (If strict locking is required around every mutation, wrap logic in
|
||||
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||
|
||||
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *cart_messages.ChangeQuantity) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||
}
|
||||
@@ -48,23 +50,18 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
|
||||
}
|
||||
|
||||
if m.Quantity <= 0 {
|
||||
// Remove the item
|
||||
itemToRemove := g.Items[foundIndex]
|
||||
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.After(time.Now()) {
|
||||
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
|
||||
if err != nil {
|
||||
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
|
||||
}
|
||||
}
|
||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
// Setting a parent line to 0 means "remove it AND its accessories".
|
||||
// Delegate to RemoveItem so the cascade logic in one place stays
|
||||
// the source of truth for descendant cleanup + reservation
|
||||
// release — otherwise we'd duplicate the transitive cascade here.
|
||||
return c.RemoveItem(g, &cart_messages.RemoveItem{Id: m.Id})
|
||||
}
|
||||
item := g.Items[foundIndex]
|
||||
if item == nil {
|
||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||
}
|
||||
if g.Type == messages.CartType_REGULAR && c.UseReservations(item) {
|
||||
oldQty := item.Quantity
|
||||
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(item) {
|
||||
if item.ReservationEndTime != nil {
|
||||
err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
|
||||
if err != nil {
|
||||
@@ -79,6 +76,14 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
|
||||
item.ReservationEndTime = endTime
|
||||
}
|
||||
item.Quantity = uint16(m.Quantity)
|
||||
// Rescale any direct child lines proportionally so accessories stay
|
||||
// in sync with their parent. Descends recursively into grandchildren
|
||||
// via CascadeChildQuantities itself. Skipped when the target itself
|
||||
// is a child line — direct qty edits on a child are treated as
|
||||
// explicit user intent and don't propagate further.
|
||||
if item.ParentId == nil {
|
||||
c.CascadeChildQuantities(ctx, g, item.Id, oldQty, item.Quantity)
|
||||
}
|
||||
g.UpdateTotals()
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
// Parent qty 1 → 2 resizes every direct child proportionally. Children all
|
||||
// start at qty 1 so the proportional factor of 2 doubles them.
|
||||
func TestChangeQuantity_ParentCascadesDoublingChildren(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||
parentLine := g.Items[0].Id
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine})
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 2}); err != nil {
|
||||
t.Fatalf("change qty: %v", err)
|
||||
}
|
||||
|
||||
if g.Items[0].Quantity != 2 {
|
||||
t.Errorf("parent qty = %d, want 2", g.Items[0].Quantity)
|
||||
}
|
||||
for _, it := range g.Items {
|
||||
if it.Id == parentLine {
|
||||
continue
|
||||
}
|
||||
if it.Quantity != 2 {
|
||||
t.Errorf("child %s qty = %d, want 2 (cascaded from parent)", it.Sku, it.Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parent qty 2 → 3 with children at qty 2 → 3 (factor 3/2 = 1.5, integer math).
|
||||
func TestChangeQuantity_ParentCascadesNonIntegerRatio(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 2, Price: 1000})
|
||||
parentLine := g.Items[0].Id
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 2, Price: 100, ParentId: &parentLine})
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 3}); err != nil {
|
||||
t.Fatalf("change qty: %v", err)
|
||||
}
|
||||
|
||||
// parent: 2 -> 3 (direct set), child: 2 * 3 / 2 = 3
|
||||
if g.Items[0].Quantity != 3 {
|
||||
t.Errorf("parent qty = %d, want 3", g.Items[0].Quantity)
|
||||
}
|
||||
if g.Items[1].Quantity != 3 {
|
||||
t.Errorf("child qty = %d, want 3 (proportional scale)", g.Items[1].Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// Scale-down that would round to 0 (parent 2 → 1, child qty 1 → (1*1)/2 = 0)
|
||||
// must clamp to 1 so the child is never silently dropped by integer math.
|
||||
func TestChangeQuantity_ParentScaleDownFloorsAtOne(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 2, Price: 1000})
|
||||
parentLine := g.Items[0].Id
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 1}); err != nil {
|
||||
t.Fatalf("change qty: %v", err)
|
||||
}
|
||||
|
||||
if g.Items[0].Quantity != 1 {
|
||||
t.Errorf("parent qty = %d, want 1", g.Items[0].Quantity)
|
||||
}
|
||||
if g.Items[1].Quantity != 1 {
|
||||
t.Errorf("child qty = %d, want 1 (floor; integer math dropped to 0 without clamp)", g.Items[1].Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// Setting parent qty to 0 delegates to RemoveItem so the existing parent→
|
||||
// child cascade takes over. Unrelated lines are left untouched.
|
||||
func TestChangeQuantity_ZeroCascadesRemoval(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||
parentLine := g.Items[0].Id
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 200, Sku: "OTHER", Quantity: 1, Price: 500})
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 0}); err != nil {
|
||||
t.Fatalf("change qty 0: %v", err)
|
||||
}
|
||||
|
||||
if len(g.Items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (only OTHER survives)", len(g.Items))
|
||||
}
|
||||
if g.Items[0].Sku != "OTHER" {
|
||||
t.Errorf("remaining sku = %q, want OTHER", g.Items[0].Sku)
|
||||
}
|
||||
}
|
||||
|
||||
// Changing a child's qty directly updates only that child — siblings and
|
||||
// the parent are untouched. (The UI no longer exposes this control, but
|
||||
// the HTTP surface still allows it for tools & direct API calls.)
|
||||
func TestChangeQuantity_ChildDoesNotCascade(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||
parentLine := g.Items[0].Id
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine})
|
||||
targetChild := g.Items[1].Id
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: targetChild, Quantity: 3}); err != nil {
|
||||
t.Fatalf("change child qty: %v", err)
|
||||
}
|
||||
|
||||
for _, it := range g.Items {
|
||||
switch it.Id {
|
||||
case parentLine:
|
||||
if it.Quantity != 1 {
|
||||
t.Errorf("parent qty = %d, want 1 (untouched on child edit)", it.Quantity)
|
||||
}
|
||||
case targetChild:
|
||||
if it.Quantity != 3 {
|
||||
t.Errorf("target child qty = %d, want 3", it.Quantity)
|
||||
}
|
||||
default:
|
||||
// Sibling
|
||||
if it.Quantity != 1 {
|
||||
t.Errorf("sibling %s qty = %d, want 1 (untouched on child edit)", it.Sku, it.Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cascade descends into grandchildren so deeply nested accessory trees
|
||||
// remain internally consistent with the top-level parent change.
|
||||
func TestChangeQuantity_NestedChildren(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||
parentLine := g.Items[0].Id
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||
childLine := g.Items[1].Id
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "G1", Quantity: 1, Price: 50, ParentId: &childLine})
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 2}); err != nil {
|
||||
t.Fatalf("change qty: %v", err)
|
||||
}
|
||||
|
||||
// parent 1 -> 2; child 1 -> 2 (parent -> child cascade); grandchild 1 -> 2 (child -> grandchild cascade)
|
||||
for _, it := range g.Items {
|
||||
if it.Quantity != 2 {
|
||||
t.Errorf("line %s qty = %d, want 2 (nested cascade)", it.Sku, it.Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
||||
}
|
||||
existing.ReservationEndTime = endTime
|
||||
}
|
||||
oldQty := existing.Quantity
|
||||
existing.Quantity += uint16(m.Quantity)
|
||||
existing.Stock = uint16(m.Stock)
|
||||
existing.InventoryTracked = m.InventoryTracked
|
||||
@@ -124,6 +125,20 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
||||
if len(m.CustomFields) > 0 {
|
||||
existing.CustomFields = m.CustomFields
|
||||
}
|
||||
// Re-add grew the parent's qty — rescale direct child quantities
|
||||
// proportionally so a "+1 drill" keeps its accessories consistent.
|
||||
// Skip when the merged line IS itself a child (ParentId != nil):
|
||||
// cascading up the tree isn't a thing — the parent would not know
|
||||
// a child grew without re-running the promotion engine.
|
||||
if existing.ParentId == nil {
|
||||
c.CascadeChildQuantities(ctx, g, existing.Id, oldQty, existing.Quantity)
|
||||
}
|
||||
// Recompute totals so TotalPrice/TotalDiscount/Discount annotations
|
||||
// reflect the merged parent + cascaded children. The non-merge
|
||||
// (new-line) path below also calls UpdateTotals; the merge path
|
||||
// would otherwise drift after a "+1" re-add, especially now that
|
||||
// cascade may have rewritten N child quantities.
|
||||
g.UpdateTotals()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+21
-79
@@ -17,6 +17,11 @@ import (
|
||||
// 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.
|
||||
//
|
||||
// The per-line breakdown (cart.EvaluatedItem) is computed via the canonical
|
||||
// cart.MapEvaluatedItems so the preview and post-add responses share shape
|
||||
// and rounding rules — see pkg/cart/evaluated_item.go for the type and the
|
||||
// map function.
|
||||
|
||||
// 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
|
||||
@@ -46,88 +51,15 @@ type EvaluateRequest struct {
|
||||
// promotions, the per-promotion breakdown (applied discounts plus pending
|
||||
// "spend X more for ..." nudges), and the per-line breakdown so the
|
||||
// storefront can render the real (post-discount) unit price for each item.
|
||||
//
|
||||
// Items is the same []cart.EvaluatedItem shape the live cart exposes on
|
||||
// CartGrain.EvaluatedItems — referenced from cart so the preview and the
|
||||
// live response match byte-for-byte on the wire.
|
||||
type EvaluateResponse struct {
|
||||
TotalPrice *cart.Price `json:"totalPrice"`
|
||||
TotalDiscount *cart.Price `json:"totalDiscount"`
|
||||
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
||||
Items []EvaluatedItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// EvaluatedItem is one line in the EvaluateResponse.Items list. It mirrors
|
||||
// the line that was evaluated (sku, quantity, unit list price, optional
|
||||
// OrgPrice for the strikethrough) and adds the per-line discount the
|
||||
// engine applied (orgPrice-based + promotion-based, additive) plus the
|
||||
// resulting effective per-unit and per-line totals. Distributing the
|
||||
// total discount down to the line level lets the storefront render
|
||||
// "Item X: 100 kr → 80 kr" without re-doing the math on the client.
|
||||
type EvaluatedItem struct {
|
||||
Sku string `json:"sku"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Quantity uint16 `json:"qty"`
|
||||
PriceIncVat int64 `json:"priceIncVat"` // per-unit list price
|
||||
OrgPriceIncVat int64 `json:"orgPriceIncVat,omitempty"` // per-unit pre-discount list price (for strikethrough)
|
||||
DiscountIncVat int64 `json:"discountIncVat"` // per-line TOTAL discount (orgPrice + promotion), incVat in öre
|
||||
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` // per-unit, after discount, incVat in öre (rounded)
|
||||
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` // per-line, after discount, incVat in öre (exact)
|
||||
}
|
||||
|
||||
// MapEvaluatedItems walks the grain's items after ApplyResults and
|
||||
// produces the per-line EvaluatedItem list. The grain's item.Discount
|
||||
// carries the TOTAL per-line discount (orgPrice + promotion — additive,
|
||||
// set by UpdateTotals and the effects' per-line distribution). The
|
||||
// effective totals are derived as (price * qty - discount), clamped to
|
||||
// 0 (a promotion that over-discounted a line shows as free, not
|
||||
// negative). The effective per-unit price is the per-line total
|
||||
// divided by quantity, rounded to the nearest öre so the per-unit
|
||||
// display matches the per-line total when multiplied back.
|
||||
// Exported so the /promotions/evaluate-with-cart preview handler in
|
||||
// cmd/cart can produce the same per-line shape the stateless
|
||||
// Evaluate path produces.
|
||||
func MapEvaluatedItems(g *cart.CartGrain) []EvaluatedItem {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]EvaluatedItem, 0, len(g.Items))
|
||||
for _, it := range g.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
var name string
|
||||
if it.Meta != nil {
|
||||
name = it.Meta.Name
|
||||
}
|
||||
var orgPrice int64
|
||||
if it.OrgPrice != nil {
|
||||
orgPrice = it.OrgPrice.IncVat.Int64()
|
||||
}
|
||||
var discount int64
|
||||
if it.Discount != nil {
|
||||
discount = it.Discount.IncVat.Int64()
|
||||
}
|
||||
priceRow := it.Price.IncVat.Int64() * int64(it.Quantity)
|
||||
effTotal := priceRow - discount
|
||||
if effTotal < 0 {
|
||||
effTotal = 0
|
||||
}
|
||||
var effUnit int64
|
||||
if it.Quantity > 0 {
|
||||
// Nearest-öre rounding so per-unit * qty matches the
|
||||
// per-line total (within 1 öre either way). Half-up:
|
||||
// (effTotal + qty/2) / qty.
|
||||
effUnit = (effTotal + int64(it.Quantity)/2) / int64(it.Quantity)
|
||||
}
|
||||
out = append(out, EvaluatedItem{
|
||||
Sku: it.Sku,
|
||||
Name: name,
|
||||
Quantity: it.Quantity,
|
||||
PriceIncVat: it.Price.IncVat.Int64(),
|
||||
OrgPriceIncVat: orgPrice,
|
||||
DiscountIncVat: discount,
|
||||
EffectivePriceIncVat: effUnit,
|
||||
EffectiveTotalIncVat: effTotal,
|
||||
})
|
||||
}
|
||||
return out
|
||||
Items []cart.EvaluatedItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// Evaluate runs rules against a synthetic cart built from req and returns the
|
||||
@@ -139,7 +71,7 @@ func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest)
|
||||
TotalPrice: g.TotalPrice,
|
||||
TotalDiscount: g.TotalDiscount,
|
||||
AppliedPromotions: g.AppliedPromotions,
|
||||
Items: MapEvaluatedItems(g),
|
||||
Items: cart.MapEvaluatedItems(g),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +129,16 @@ func (s *PromotionService) EvaluateAndApply(rules []PromotionRule, g *cart.CartG
|
||||
results, _ = s.EvaluateAll(rules, ctx)
|
||||
}
|
||||
s.ApplyResults(g, results, ctx)
|
||||
// Populate the per-line breakdown now that ApplyResults is done —
|
||||
// every effect has distributed per-line Discounts onto the grain's
|
||||
// items, so MapEvaluatedItems reads consistent state. Sits on the
|
||||
// canonical pipeline so callers (live-cart processor, the cart-aware
|
||||
// preview handler, internal/admin mutations) never have to remember
|
||||
// to refresh it themselves; downstream readers (UCP cart response,
|
||||
// legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) see a
|
||||
// grain that already carries the per-line breakdown on its own
|
||||
// EvaluatedItems field.
|
||||
g.EvaluatedItems = cart.MapEvaluatedItems(g)
|
||||
}
|
||||
|
||||
// markCouponsBypassed scans the evaluation results for any applicable
|
||||
|
||||
Reference in New Issue
Block a user