220 lines
7.2 KiB
Go
220 lines
7.2 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
messages "git.k6n.net/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 * 100)
|
||
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
|
||
}
|