parent item mutations
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s

This commit is contained in:
2026-07-08 11:39:42 +02:00
parent e20793a6b3
commit 9f1eca07e9
14 changed files with 784 additions and 103 deletions
+13 -1
View File
@@ -161,7 +161,19 @@ type CartGrain struct {
Items []*CartItem `json:"items"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
Processing bool `json:"processing"`
// 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"`
+69
View File
@@ -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()
+95
View File
@@ -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
}
+30
View File
@@ -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)
}
}
}
+22 -17
View File
@@ -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
+170
View File
@@ -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)
}
}
}
+15
View File
@@ -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
View File
@@ -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