fixes
This commit is contained in:
+180
-111
@@ -4,34 +4,77 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
|
||||
"github.com/matst80/slask-finder/pkg/index"
|
||||
)
|
||||
|
||||
// TODO make this configurable
|
||||
func getBaseUrl(country string) string {
|
||||
// if country == "se" {
|
||||
// return "http://s10n-se:8080"
|
||||
// }
|
||||
if country == "no" {
|
||||
return "http://s10n-no.s10n:8080"
|
||||
}
|
||||
if country == "se" {
|
||||
return "http://s10n-se.s10n:8080"
|
||||
}
|
||||
return "http://s10n-se.s10n:8080"
|
||||
// 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",
|
||||
}
|
||||
|
||||
func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem, error) {
|
||||
// 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)
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku), nil)
|
||||
innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
|
||||
defer span.End()
|
||||
req = req.WithContext(innerCtx)
|
||||
req, err := http.NewRequestWithContext(innerCtx, http.MethodGet, fmt.Sprintf("%s/api/get/%s", baseUrl, sku), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -40,111 +83,137 @@ func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var item index.DataItem
|
||||
err = json.NewDecoder(res.Body).Decode(&item)
|
||||
return &item, err
|
||||
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, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return ToItemAddMessage(item, storeId, qty, country)
|
||||
}
|
||||
|
||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
||||
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||
|
||||
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||
msg, err := ToItemAddMessage(item, parent, storeId, qty, country)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
stk := item.GetStock()
|
||||
stock := cart.StockStatus(0)
|
||||
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 {
|
||||
centralStock, ok := stk[country]
|
||||
if ok {
|
||||
if !item.Buyable {
|
||||
return nil, fmt.Errorf("item not available")
|
||||
}
|
||||
stock = item.InStock
|
||||
}
|
||||
|
||||
if centralStock == 0 && item.SaleStatus == "TBD" {
|
||||
return nil, fmt.Errorf("no items available")
|
||||
}
|
||||
stock = cart.StockStatus(centralStock)
|
||||
// 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)
|
||||
}
|
||||
|
||||
} else {
|
||||
if !item.BuyableInStore {
|
||||
return nil, fmt.Errorf("item not available in store")
|
||||
}
|
||||
storeStock, ok := stk[*storeId]
|
||||
if ok {
|
||||
stock = cart.StockStatus(storeStock)
|
||||
}
|
||||
|
||||
msg.ExtraJson = extra
|
||||
}
|
||||
|
||||
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
||||
var outlet *string
|
||||
if ok {
|
||||
outlet = &outletGrade
|
||||
}
|
||||
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
||||
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
||||
|
||||
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
||||
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
||||
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
||||
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
||||
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
||||
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
||||
|
||||
cgm, _ := item.GetStringFieldValue(35) // Customer Group Membership
|
||||
|
||||
return &messages.AddItem{
|
||||
ItemId: uint32(item.Id),
|
||||
Quantity: int32(qty),
|
||||
Price: int64(price),
|
||||
OrgPrice: int64(orgPrice),
|
||||
Sku: item.GetSku(),
|
||||
Name: item.Title,
|
||||
Image: item.Img,
|
||||
Stock: int32(stock),
|
||||
Brand: brand,
|
||||
Category: category,
|
||||
Category2: category2,
|
||||
Category3: category3,
|
||||
Category4: category4,
|
||||
Category5: category5,
|
||||
Tax: getTax(articleType),
|
||||
SellerId: sellerId,
|
||||
SellerName: sellerName,
|
||||
ArticleType: articleType,
|
||||
Disclaimer: item.Disclaimer,
|
||||
Country: country,
|
||||
Outlet: outlet,
|
||||
StoreId: storeId,
|
||||
SaleStatus: item.SaleStatus,
|
||||
Cgm: cgm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getTax(articleType string) int32 {
|
||||
switch articleType {
|
||||
case "ZDIE":
|
||||
return 600
|
||||
default:
|
||||
return 2500
|
||||
}
|
||||
}
|
||||
|
||||
func getInt(data float64, ok bool) (int, error) {
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid type")
|
||||
}
|
||||
return int(data), nil
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user