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
+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