Files
go-cart-actor/pkg/cart/cart-mutation-helper.go
T
mats 80a1e6d05e
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
mutation fixes
2026-07-08 12:17:36 +02:00

193 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
type CartMutationContext struct {
reservationService inventory.ReservationPolicy
}
func NewCartMutationContext(reservationService inventory.ReservationPolicy) *CartMutationContext {
return &CartMutationContext{
reservationService: reservationService,
}
}
func (c *CartMutationContext) ReserveItem(ctx context.Context, cartId CartId, sku string, locationId *string, quantity uint16) (*time.Time, error) {
if quantity <= 0 || c.reservationService == nil {
return nil, nil
}
l := inventory.LocationID("se")
if locationId != nil {
l = inventory.LocationID(*locationId)
}
ttl := time.Minute * 15
endTime := time.Now().Add(ttl)
err := c.reservationService.Reserve(ctx, inventory.CartReserveRequest{
CartID: inventory.CartID(cartId.String()),
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(sku),
LocationID: l,
},
TTL: ttl,
Quantity: uint32(quantity),
})
if err != nil {
return nil, err
}
return &endTime, nil
}
func (c *CartMutationContext) UseReservations(item *CartItem) bool {
if item.ReservationEndTime != nil {
return true
}
if item.DropShip {
return false
}
if item.InventoryTracked {
return true
}
return false
}
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
if c.reservationService == nil {
return nil
}
l := inventory.LocationID("se")
if locationId != nil {
l = inventory.LocationID(*locationId)
}
return c.reservationService.Release(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
}
// equalOptional is the canonical nullable-equality primitive used by the
// cart mutations when deciding whether two identifier fields (parent/child
// links, store ids, anything else nullable) refer to the same thing.
//
// Returns true when both pointers are nil, when both alias the same
// address, or when both are non-nil and dereference to equal values. The
// same-address short-circuit is a no-op for correctness but lets a caller
// who passes the same variable get back true without traversing the
// generic's comparison path.
//
// One tested primitive covers every *T the cart currently compares —
// `*uint32` for ParentId, `*string` for StoreId — and any future
// comparable pointer type. Hand-rolled "both nil OR both non-nil and
// equal" expressions were duplicated at AddItem's merge site and were a
// footgun whenever a new field was added (which axis of the predicate
// was the relevant one?). Keeping a single helper makes future merges
// safer to copy.
func equalOptional[T comparable](a, b *T) bool {
if a == b { // both nil OR same address
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
// 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()
reg.RegisterMutations(
actor.NewMutation(context.AddItem),
actor.NewMutation(context.ChangeQuantity),
actor.NewMutation(context.RemoveItem),
actor.NewMutation(ClearCart),
actor.NewMutation(AddVoucher),
actor.NewMutation(RemoveVoucher),
actor.NewMutation(UpsertSubscriptionDetails),
actor.NewMutation(SetUserId),
actor.NewMutation(LineItemMarking),
actor.NewMutation(RemoveLineItemMarking),
actor.NewMutation(SetLineItemCustomFields),
actor.NewMutation(SubscriptionAdded),
actor.NewMutation(context.SetCartType),
actor.NewMutation(SetRecoveryContact),
// actor.NewMutation(SubscriptionRemoved),
)
return reg
}