package main import ( "context" "encoding/json" "fmt" "io" "math" "net/http" "strconv" messages "git.k6n.net/mats/go-cart-actor/proto/cart" ) // consumedKeys are product-document keys that are mapped to typed AddItem // fields. They are stripped from the dynamic remainder so Extra only holds // genuinely dynamic data. var consumedKeys = []string{ "id", "sku", "title", "img", "price", "vat", "discount", "inStock", "supplierId", "supplierName", "deleted", } // getBaseUrl returns the product service base url. Overridable via // PRODUCT_BASE_URL (the country argument is currently unused but kept for when // per-market routing returns). func getBaseUrl(country string) string { return getEnv("PRODUCT_BASE_URL", "http://localhost:8082") } // ProductItem is the flat product document served by GET /api/get/{sku}. // Only the fields the cart currently maps mechanically are decoded; the rest // of the (dynamic) document is intentionally ignored until the cart item model // is reworked to carry arbitrary data. type ProductItem struct { Id uint64 `json:"id"` Sku string `json:"sku"` Title string `json:"title"` Img string `json:"img"` Price float64 `json:"price"` Vat int `json:"vat"` Discount int `json:"discount"` // percent off the original price InStock int32 `json:"inStock"` SupplierId int `json:"supplierId"` SupplierName string `json:"supplierName"` Deleted bool `json:"deleted"` // Extra is the rest of the product document (everything not mapped to a // typed field above), preserved verbatim and surfaced on the cart item. Extra map[string]json.RawMessage `json:"-"` } // numberField reads a numeric dynamic property from the product document // (width, height, ...). Like JS Number(), it accepts a JSON number or a numeric // string. Returns false when the key is absent or not numeric. func (p *ProductItem) numberField(key string) (float64, bool) { raw, ok := p.Extra[key] if !ok { return 0, false } var f float64 if err := json.Unmarshal(raw, &f); err == nil { return f, true } var s string if err := json.Unmarshal(raw, &s); err == nil { if f, err := strconv.ParseFloat(s, 64); err == nil { return f, true } } return 0, false } func FetchItem(ctx context.Context, sku string, country string) (*ProductItem, error) { baseUrl := getBaseUrl(country) innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku)) defer span.End() req, err := http.NewRequestWithContext(innerCtx, http.MethodGet, fmt.Sprintf("%s/api/get/%s", baseUrl, sku), nil) if err != nil { return nil, err } res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("product service returned %d for sku %s", res.StatusCode, sku) } body, err := io.ReadAll(res.Body) if err != nil { return nil, err } var item ProductItem if err := json.Unmarshal(body, &item); err != nil { return nil, err } // Capture everything not mapped to a typed field as dynamic data. all := map[string]json.RawMessage{} if err := json.Unmarshal(body, &all); err != nil { return nil, err } for _, k := range consumedKeys { delete(all, k) } if len(all) > 0 { item.Extra = all } return &item, nil } func GetItemAddMessage(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) { msg, _, err := BuildItemMessage(ctx, sku, qty, country, storeId, nil) return msg, err } // BuildItemMessage fetches a product and builds its AddItem mutation, returning // the fetched product too so it can serve as the parent for child items. When // parent is non-nil the line is priced as a child of that parent. func BuildItemMessage(ctx context.Context, sku string, qty int, country string, storeId *string, parent *ProductItem) (*messages.AddItem, *ProductItem, error) { item, err := FetchItem(ctx, sku, country) if err != nil { return nil, nil, err } msg, err := ToItemAddMessage(item, parent, storeId, qty, country) if err != nil { return nil, nil, err } return msg, item, nil } // Glass is billed at a minimum surface even when the real pane is smaller, so a // small window doesn't get a near-zero per-m² price. const minGlassArea = 0.4 // m² // areaFromItem derives the main article's *visible glass* surface in m² from a // resolved configurator selection. The catalog width/height are facet codes; // the outer (karmytter) size is `code × 100 − margin` mm and the visible glass // is that minus the frame border per axis, e.g. width 4, widthMargin 20, // borderWidth 194 → 400 − 20 − 194 = 186 mm. Area = glassW(mm) × glassH(mm) / // 1e6, clamped up to the minGlassArea billing floor. Returns 0 when a dimension // is missing/non-numeric (non-window PDP, or no product resolved yet). func areaFromItem(item *ProductItem) float64 { if item == nil { return 0 } w, okW := item.numberField("width") h, okH := item.numberField("height") if !okW || !okH || w <= 0 || h <= 0 { return 0 } glassW := w * 100 // - (item.numberField("widthMargin") + item.numberField("borderWidth")) glassH := h * 100 // - (item.numberField("heightMargin") + item.numberField("borderHeight")) if glassW <= 0 || glassH <= 0 { return 0 } return math.Max((glassW*glassH)/1_000_000, minGlassArea) } // accessoryPrice computes a child line's unit price (inc vat, minor units): the // child's own per-m² price scaled by the parent window's billed glass area. // When the parent has no usable dimensions the area is 0, so the price is 0. func accessoryPrice(parent *ProductItem, child *ProductItem) int64 { area := areaFromItem(parent) return int64(child.Price*100*area + 0.5) } // orgPriceFromDiscount reconstructs the pre-discount price from a percentage. // Returns 0 when there is no usable discount, in which case OrgPrice is omitted. func orgPriceFromDiscount(price int64, discountPercent int) int64 { if discountPercent <= 0 || discountPercent >= 100 { return 0 } return int64(float64(price)/(1-float64(discountPercent)/100) + 0.5) } func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, qty int, country string) (*messages.AddItem, error) { // Central stock only: store-specific stock is no longer in the payload, so a // storeId request currently resolves to zero stock. var stock int32 if storeId == nil { stock = item.InStock } // Top-level items price from their own product; children are priced from the // parent product via the accessory-price hook. price := int64(item.Price) if parent != nil { price = accessoryPrice(parent, item) } msg := &messages.AddItem{ ItemId: uint32(item.Id), Quantity: int32(qty), Price: price, OrgPrice: orgPriceFromDiscount(price, item.Discount), Sku: item.Sku, Name: item.Title, Image: item.Img, Stock: stock, Tax: int32(item.Vat * 100), SellerId: strconv.Itoa(item.SupplierId), SellerName: item.SupplierName, Country: country, StoreId: storeId, } if len(item.Extra) > 0 { extra, err := json.Marshal(item.Extra) if err != nil { return nil, fmt.Errorf("marshal extra product data: %w", err) } msg.ExtraJson = extra } return msg, nil }