This commit is contained in:
2026-06-28 18:47:02 +02:00
parent 63b0112cc7
commit e9e9700a09
2 changed files with 109 additions and 15 deletions
+22 -6
View File
@@ -75,6 +75,7 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id c
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
sku := r.PathValue("sku") sku := r.PathValue("sku")
country := getCountryFromHost(r.Host)
if s.idx != nil && s.idx.IsDeleted(sku) { if s.idx != nil && s.idx.IsDeleted(sku) {
// Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the // Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the
// product-fetcher's "product service returned %d for sku %s" shape so // product-fetcher's "product service returned %d for sku %s" shape so
@@ -86,13 +87,28 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
http.Error(w, fmt.Sprintf("product service returned %d for sku %s (bus-deleted)", 404, sku), http.StatusNotFound) http.Error(w, fmt.Sprintf("product service returned %d for sku %s (bus-deleted)", 404, sku), http.StatusNotFound)
return nil return nil
} }
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
if err != nil { // Cache-only fast path: AddSkuToCartHandler is a SINGLE top-level add
return err // (qty=1, no children). When the projection carries full authoritative
} // fields, we skip PRODUCT_BASE_URL entirely and build the AddItem
// directly from the cache. This eliminates one HTTP round-trip per
// add-to-cart under steady-state bus-fed conditions.
var msg *messages.AddItem
if s.idx != nil { if s.idx != nil {
if p, ok := s.idx.Get(sku); ok { if p, ok := s.idx.Get(sku); ok && HasRequiredFields(p) {
ApplyProjectionOverlay(msg, p) msg = BuildAddItemFromCache(sku, 1, country, nil, p)
}
}
if msg == nil {
var err error
msg, err = GetItemAddMessage(r.Context(), sku, 1, country, nil)
if err != nil {
return err
}
if s.idx != nil {
if p, ok := s.idx.Get(sku); ok {
ApplyProjectionOverlay(msg, p)
}
} }
} }
+87 -9
View File
@@ -16,11 +16,11 @@ import (
// gets the common case right (2500 / 1200 / 600 / 0) and lets the cache path // gets the common case right (2500 / 1200 / 600 / 0) and lets the cache path
// ship while the platform/tax lookup is wired into the cart pool server. // ship while the platform/tax lookup is wired into the cart pool server.
var taxClassToBp = map[string]int{ var taxClassToBp = map[string]int{
"standard": 2500, "standard": 2500,
"reduced": 1200, // common reduced rate (e.g. SE/NO food) "reduced": 1200, // common reduced rate (e.g. SE/NO food)
"lowered": 600, // super-reduced "lowered": 600, // super-reduced
"zero": 0, // zero-rated (export, healthcare, ...) "zero": 0, // zero-rated (export, healthcare, ...)
"exempt": 0, // similarly untaxed, separate nominal class "exempt": 0, // similarly untaxed, separate nominal class
} }
// resolveTaxBp returns the basis-point rate for a TaxClass string, falling // resolveTaxBp returns the basis-point rate for a TaxClass string, falling
@@ -34,6 +34,82 @@ func resolveTaxBp(class string) int {
return 2500 return 2500
} }
// HasRequiredFields asserts the catalog Projection carries enough authoritative
// fields for the cart to build a complete AddItem WITHOUT an HTTP fallback to
// PRODUCT_BASE_URL. Required:
//
// PriceIncVat > 0 (treats zero as "unbus-fed", not "free"; positive-only
// matches every other cache-authoritative field)
// TaxClass != "" (string identifier; numeric rate resolved via
// resolveTaxBp at the same scale as the HTTP path)
// ItemID != 0 (uint32 dedup key; zero means the producer didn't
// publish one, so we don't claim we know the id)
//
// Display fields (Title, Image) and fulfillment flags (InventoryTracked,
// DropShip) are optional — when present they populate the line, when absent
// the proto-zero value is acceptable.
func HasRequiredFields(p catalog.Projection) bool {
return p.PriceIncVat.Int64() > 0 &&
p.TaxClass != "" &&
p.ItemID != 0
}
// BuildAddItemFromCache constructs a complete AddItem message directly from a
// cache hit that satisfies HasRequiredFields. The caller has already confirmed
// the projection is well-formed; we map every cache-visible carrier onto the
// proto fields the existing HTTP path would have constructed.
//
// Fields mapped:
//
// Sku ← input
// ItemId ← p.ItemID (uint32 dedup key)
// Quantity ← input
// Country ← input
// StoreId ← input
// Price ← p.PriceIncVat.Int64()
// Tax ← int32(resolveTaxBp(p.TaxClass))
// Name ← p.Title (may be empty)
// Image ← p.Image (may be empty)
// InventoryTracked ← p.InventoryTracked
// DropShip ← p.DropShip
//
// Fields intentionally left blank (cache-only gap; the schema doesn't yet
// carry them and the audience for those fields is downstream of the cart line
// — they are NOT required for line dedup or pricing):
//
// SellerId / SellerName — marketplace split; not on Projection yet.
// Stock — queried synchronously from inventory at
// decision points (per docs/inventory-shape.md).
// OrgPrice / Discount — pre-discount price; a future Projection
// extension when promotion previews need it.
// ExtraJson — dynamic product data (configurator
// width/height etc.); a future schema extension
// for cache-only-skip-HTTP for groups with
// configurator children.
//
// IMPORTANT: this helper is for TOP-LEVEL lines only. Configurator children
// re-price based on parent dimensions (ToItemAddMessage's parent != nil
// path); since the Projection does not yet carry width/height, callers must
// keep the existing HTTP-fetch flow for groups with children.
//
// Tombstoned SKUs never reach this helper; callers check idx.IsDeleted(sku)
// first and short-circuit.
func BuildAddItemFromCache(sku string, qty int, country string, storeId *string, p catalog.Projection) *messages.AddItem {
return &messages.AddItem{
Sku: sku,
ItemId: p.ItemID,
Quantity: int32(qty),
Country: country,
StoreId: storeId,
Price: p.PriceIncVat.Int64(),
Tax: int32(resolveTaxBp(p.TaxClass)),
Name: p.Title,
Image: p.Image,
InventoryTracked: p.InventoryTracked,
DropShip: p.DropShip,
}
}
// ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto // ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto
// an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch. // an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch.
// //
@@ -61,10 +137,12 @@ func resolveTaxBp(class string) int {
// ExtraJson product data (configurator options — width/height used by // ExtraJson product data (configurator options — width/height used by
// accessory child-pricing). // accessory child-pricing).
// //
// Now that ItemID round-trips from the bus, a future "cache-only" build path // SKUs that satisfy HasRequiredFields AND have no configurator children
// (skipping PRODUCT_BASE_URL entirely for known SKUs) is unblocked — the // route through BuildAddItemFromCache instead — see the cache-only fast path
// only fields left HTTP-only in that future path are Sellers, Stock, OrgPrice // in buildItemGroups / AddSkuToCartHandler. ApplyProjectionOverlay remains
// and configurator extras, none of which affect line dedup. // for the cold-grace case (unbus-fed cache entry) AND for groups with
// children (the parent's HTTP fetch is required for child dimension-driven
// pricing; Overlay is applied after the fetch).
// //
// Tombstoned SKUs (catalog.projection_published with deleted=true) → the // Tombstoned SKUs (catalog.projection_published with deleted=true) → the
// caller checks idx.IsDeleted(sku) before this helper and short-circuits. // caller checks idx.IsDeleted(sku) before this helper and short-circuits.