Files
platform/catalog/builder.go
T
2026-08-23 13:43:40 +02:00

516 lines
16 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.
// Builder applies a Schema to a generic source map[string]any and returns
// the platform-projected Projection. Pure function — no I/O, no mutation,
// safe to call concurrently. Coercions are best-effort:
//
// - missing source field ⇒ Schema default (FixedCurrency/DefaultTaxClass
// when present, otherwise empty/zero)
// - wrong-typed source value ⇒ the Builder either coerces (bools from
// truthy strings, ints from numeric strings, json.Number, etc.) or
// returns ErrProjectionBuild so the caller can decide whether to
// log+skip or fail.
//
// Builder never panics on bad input — a malformed source line is a log+skip,
// not a process crash.
//
// Three coercers cover realistic producer output shapes:
//
// - stock json.Unmarshal gives numeric fields as float64 unless
// `dec.UseNumber()` is on, in which case they come as json.Number.
// - json.Number preserves up to ~17 digits of precision; float64
// silently loses precision past 2^53. A producer that wants exact
// prices sets UseNumber(); this Builder must accept the result.
// - some producers serialise via reflection and emit `int` / `int64`
// directly; this Builder must accept those too.
//
// All amounts leave the Builder as money.Cents (integer minor units). The
// only float-side conversion lives in platform/money (FromFloatMajor) and
// is the explicit lossy-edge; once in Cents, no float is ever summed,
// multiplied, or compared downstream.
//
// SCALE SEMANTICS used by coercePrice:
//
// - PriceScale == 1: the source value is ALREADY in minor units.
// * For string / json.Number: ParseInt directly (integer form only —
// the fractional-vs-low-scale guard above rejects "."-containing
// inputs).
// * For int / int64: identity (multiply by 1).
// * For float64 / float32: the float must be integral (else rejected);
// cast to int gives minor units directly.
//
// - PriceScale > 1 (e.g. 100): the source value is in MAJOR units.
// * For string / json.Number: ParseCents treats the decimal form as
// MAJOR and returns MINOR cents exactly — no further multiply.
// * For int / int64: integer multiply (lossless).
// * For float64 / float32: float multiply + round-half-away (lossy).
package catalog
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.k6n.net/mats/platform/money"
)
// ErrProjectionBuild is returned when a source value cannot be coerced
// into the Projection shape. Callers typically log + skip so a single bad
// item doesn't poison a whole batch.
var ErrProjectionBuild = errors.New("catalog: projection build failed")
// Apply is the pure helper signature: schema, source map → Projection,
// error. Use the package-level BuildFromMap helper for the most common
// shape (schema + map → Projection); use Apply when you want fine control.
func Apply(s Schema, source map[string]any) (Projection, error) {
if s.SKUField == "" || s.PriceField == "" {
return Projection{}, fmt.Errorf("%w: schema missing sku_field or price_field", ErrProjectionBuild)
}
if source == nil {
return Projection{}, fmt.Errorf("%w: nil source map", ErrProjectionBuild)
}
sku := stringOf(source, s.SKUField)
if sku == "" {
return Projection{}, fmt.Errorf("%w: missing SKU at %q", ErrProjectionBuild, s.SKUField)
}
priceRaw, ok := source[s.PriceField]
if !ok {
return Projection{}, fmt.Errorf("%w: missing price at %q", ErrProjectionBuild, s.PriceField)
}
price, err := coercePrice(priceRaw, s.PriceScale)
if err != nil {
return Projection{}, fmt.Errorf("%w: price at %q: %w", ErrProjectionBuild, s.PriceField, err)
}
cur := ""
if s.CurrencyField != "" {
cur = stringOf(source, s.CurrencyField)
}
if cur == "" {
cur = s.FixedCurrency
}
tax := ""
if s.TaxClassField != "" {
tax = stringOf(source, s.TaxClassField)
}
if tax == "" {
tax = s.DefaultTaxClass
}
id := sku
if s.IDPrefix != "" {
id = s.IDPrefix + sku
}
upd, err := coerceUpdatedAt(s, source)
if err != nil {
return Projection{}, fmt.Errorf("%w: updated_at: %w", ErrProjectionBuild, err)
}
truthy := s.TrueValues
if truthy == nil {
truthy = DefaultTrueValues()
}
var attrs json.RawMessage
if len(s.AttributesFields) > 0 {
m := make(map[string]any)
for _, key := range s.AttributesFields {
if val, ok := source[key]; ok && val != nil {
m[key] = val
}
}
if len(m) > 0 {
b, err := json.Marshal(m)
if err != nil {
return Projection{}, fmt.Errorf("%w: marshal attributes: %w", ErrProjectionBuild, err)
}
attrs = b
}
}
p := Projection{
ItemID: uint32Of(source, s.ItemIdField),
ID: id,
SKU: sku,
Title: stringOf(source, s.TitleField),
PriceIncVat: price,
Currency: cur,
Attributes: attrs,
TaxClass: tax,
InventoryTracked: boolOf(source, s.InventoryTrackedF, truthy),
DropShip: boolOf(source, s.DropShipF, truthy),
SellerID: uint32Of(source, s.SellerIdField),
SellerName: stringOf(source, s.SellerNameField),
LeadTimeDays: intOf(source, s.LeadTimeField),
Category: stringOf(source, s.CategoryField),
Category2: stringOf(source, s.Category2Field),
Category3: stringOf(source, s.Category3Field),
Category4: stringOf(source, s.Category4Field),
Category5: stringOf(source, s.Category5Field),
Brand: stringOf(source, s.BrandField),
Image: stringOf(source, s.ImageField),
UpdatedAt: upd,
}
return p, nil
}
// BuildFromMap is the convenience: schema + map → Projection with error.
// Use this from producer adapters where the source is a parsed map[string]any.
func BuildFromMap(s Schema, source map[string]any) (Projection, error) {
return Apply(s, source)
}
// stringOf returns the value at key as a string. JSON unmarshalling gives
// strings as string; we accept the common numeric source shapes too so a
// map[string]any that came from a different upstream parser (e.g. typed
// structs serialised with one round of reflection) still works.
//
// json.Number is included because precision-preserving producers turn it on
// via json.Decoder.UseNumber(); without this case the Builder would reject
// every projection in the catalogue.
func stringOf(m map[string]any, key string) string {
if key == "" {
return ""
}
v, ok := m[key]
if !ok || v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case json.Number:
// Use Number preserves up to ~17 digits as a string-token. For SKUs
// digit-strings >2^53 (rare but possible), UseNumber is the only
// way to round-trip safely. String() gives back the raw digits.
return x.String()
case bool:
if x {
return "true"
}
return "false"
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case int:
return strconv.Itoa(x)
case int64:
return strconv.FormatInt(x, 10)
}
return fmt.Sprintf("%v", v)
}
// uint32Of returns the value at key as a uint32. Best-effort coercion:
// json.Number / int / int64 / float (integral check) / truthy numeric
// string. Returns 0 for any missing / non-uint32-representable value —
// Projection.ItemID = 0 is the documented "no id" signal that the cart's
// overlay path uses to fall back to PRODUCT_BASE_URL.
func uint32Of(m map[string]any, key string) uint32 {
if key == "" {
return 0
}
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch x := v.(type) {
case uint32:
return x
case uint64:
if x > 0xFFFFFFFF {
return 0 // overflow – treat as unset, downstream falls back
}
return uint32(x)
case int:
if x < 0 || x > 0xFFFFFFFF {
return 0
}
return uint32(x)
case int64:
if x < 0 || x > 0xFFFFFFFF {
return 0
}
return uint32(x)
case float64:
if x != float64(uint32(x)) || x < 0 || x > 0xFFFFFFFF {
return 0 // fractional or out-of-range
}
return uint32(x)
case json.Number:
s := x.String()
n, err := strconv.ParseUint(s, 10, 32)
if err != nil {
return 0
}
return uint32(n)
case string:
s := strings.TrimSpace(x)
if s == "" {
return 0
}
n, err := strconv.ParseUint(s, 10, 32)
if err != nil {
return 0
}
return uint32(n)
}
return 0
}
// intOf returns the value at key as an int (floor for fractional floats).
// Missing / non-numeric / negative values return 0 — Projection.LeadTimeDays
// = 0 is the documented "unset" signal, mirroring uint32Of's contract.
func intOf(m map[string]any, key string) int {
if key == "" {
return 0
}
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch x := v.(type) {
case int:
if x < 0 {
return 0
}
return x
case int64:
if x < 0 {
return 0
}
return int(x)
case float64:
if x < 0 {
return 0
}
return int(x)
case json.Number:
n, err := x.Int64()
if err != nil || n < 0 {
return 0
}
return int(n)
case string:
s := strings.TrimSpace(x)
if s == "" {
return 0
}
n, err := strconv.ParseFloat(s, 64)
if err != nil || n < 0 {
return 0
}
return int(n)
}
return 0
}
// boolOf returns the value at key coerced through truthy. nil/missing keys
// are false; strings lower-cased and matched against truthy; bools passed
// through; numerics "!= 0"; json.Number goes through Float64.
func boolOf(m map[string]any, key string, truthy []string) bool { if key == "" {
return false
}
v, ok := m[key]
if !ok || v == nil {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
low := strings.ToLower(strings.TrimSpace(x))
for _, t := range truthy {
if low == t {
return true
}
}
return false
case float64:
return x != 0
case int:
return x != 0
case int64:
return x != 0
case json.Number:
if f, err := x.Float64(); err == nil {
return f != 0
}
return false
}
return false
}
// coercePrice normalises a price source value to money.Cents (integer minor
// units). The schema's PriceScale determines whether the source is already
// in minor units (scale==1) or in MAJOR units that need interpretation
// (scale > 1, typically 100). See the package doc for the full table.
//
// The fractional-vs-low-scale guard REJECTS a fractional value when scale ≤ 1
// at any of the float / string / json.Number arms — that's almost certainly a
// tenant who forgot PriceScale=100 and would have produced a silent 100×-off
// price. Loud-fail beats silent corruption.
//
// At scale > 1, decimals come through money.ParseCents (exact, integer-only)
// rather than ParseFloat (lossy past 2^53). Float arms remain the explicit
// lossy bits, intentionally isolated.
func coercePrice(v any, scale int) (money.Cents, error) {
if scale < 0 {
return 0, fmt.Errorf("negative price_scale: %d", scale)
}
effective := int64(scale)
if effective < 1 {
effective = 1
}
// Fractional-vs-low-scale guard is inlined at each arm so the error
// message keeps the actual value (the float stringification is noisier
// than the JSON-token ".eE" check, so a single helper would lose
// fidelity).
switch x := v.(type) {
case float64:
if x != float64(int64(x)) && effective == 1 {
return 0, fmt.Errorf("fractional price %v with PriceScale=1 — did you forget PriceScale=100?", x)
}
return money.Cents(roundHalfAwayFromZero(x * float64(effective))), nil
case float32:
if x != float32(int64(x)) && effective == 1 {
return 0, fmt.Errorf("fractional price %v with PriceScale=1 — did you forget PriceScale=100?", x)
}
return money.Cents(roundHalfAwayFromZero(float64(x) * float64(effective))), nil
case int:
return money.Cents(int64(x) * effective), nil
case int64:
return money.Cents(x * effective), nil
case string:
s := strings.TrimSpace(x)
if s == "" {
return 0, fmt.Errorf("empty price string")
}
// Scale 1 with a fractional character set ⇒ foot-gun 4.
if effective == 1 && strings.ContainsAny(s, ".eE") {
return 0, fmt.Errorf("fractional price %q with PriceScale=1 — did you forget PriceScale=100?", s)
}
if effective == 1 {
// Scale 1 means source IS minor units. Remaining forms are
// integer decimal strings — ParseInt directly. ParseCents would
// misread "14995" as 14995.00 SEK = 1499500.
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse price %q at scale=1: %w", x, err)
}
return money.Cents(n), nil
}
// Scale > 1: source is MAJOR units. ParseCents interprets the
// decimal form as MAJOR and returns MINOR (cents) EXACTLY — no
// further multiply.
if c, err := money.ParseCents(s); err == nil {
return c, nil
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, fmt.Errorf("parse price %q: %w", x, err)
}
return money.Cents(roundHalfAwayFromZero(f * float64(effective))), nil
case json.Number:
s := x.String()
if effective == 1 && strings.ContainsAny(s, ".eE") {
return 0, fmt.Errorf("fractional price %q with PriceScale=1 — did you forget PriceScale=100?", s)
}
if effective == 1 {
// x is the original json.Number — Int64() works.
if i, err := x.Int64(); err == nil {
return money.Cents(i), nil
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse price json.Number %q at scale=1: %w", s, err)
}
return money.Cents(n), nil
}
// Scale > 1: ParseCents token-aware path. ParseCents interprets
// the token as MAJOR-units decimal and returns MINOR cents exactly.
if c, err := money.ParseCents(s); err == nil {
return c, nil
}
f, err := x.Float64()
if err != nil {
return 0, fmt.Errorf("parse price json.Number %q: %w", s, err)
}
return money.Cents(roundHalfAwayFromZero(f * float64(effective))), nil
}
return 0, fmt.Errorf("unsupported price type %T", v)
}
// roundHalfAwayFromZero rounds half-away-from-zero instead of banker's
// rounding so e.g. 99.995 → 100 (with scale=1) not 99, and -99.995 → -100
// not -99. We accept the very small skew in favour of deterministic
// conventional rounding for signed amounts (refunds, discounts).
func roundHalfAwayFromZero(f float64) int64 {
if f >= 0 {
return int64(f + 0.5)
}
return int64(f - 0.5)
}
// coerceUpdatedAt returns Unix-ms for the Projection.UpdatedAt stamp based
// on Schema.UpdatedAtSource.
//
// IMPORTANT contract (foot-gun 3, resolved):
//
// - StampAtPublish is the RESERVED deliberate-fallback. Builder sets
// time.Now().UnixMilli(). Every rebuild re-stamps every projection,
// invalidating every consumer's whole cache even when nothing changed.
// Pick this only when the producer genuinely has no version stamp.
//
// - UseSourceField (DefaultSchema, BygglagretSchema) reads the value
// from source[Schema.UpdatedAtField]. If the field is MISSING or the
// value can't be parsed, Apply returns ErrProjectionBuild (wrapped)
// rather than stamp-now'ing. That keeps UpdatedAt a true content
// version across rebuilds — same source content ⇒ same UpdatedAt ⇒
// consumer caches stay valid.
//
// Consumers compare UpdatedAt with "strictly greater wins" semantics; a
// silent stamp-now fallback would defeat this contract.
func coerceUpdatedAt(s Schema, source map[string]any) (int64, error) {
if s.UpdatedAtSource == StampAtPublish || s.UpdatedAtField == "" {
return time.Now().UnixMilli(), nil
}
v, ok := source[s.UpdatedAtField]
if !ok || v == nil {
return 0, fmt.Errorf("missing updated_at at %q (UseSourceField requires the source to supply it)", s.UpdatedAtField)
}
switch x := v.(type) {
case int64:
return x, nil
case int:
return int64(x), nil
case float64:
return int64(x), nil
case json.Number:
if i, err := x.Int64(); err == nil {
return i, nil
}
f, err := x.Float64()
if err != nil {
return 0, fmt.Errorf("updated_at json.Number %q: %w", x.String(), err)
}
return int64(f), nil
case string:
t := strings.TrimSpace(x)
if t == "" {
return 0, fmt.Errorf("empty updated_at string at %q (UseSourceField requires a value)", s.UpdatedAtField)
}
n, err := strconv.ParseInt(t, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse updated_at %q: %w", x, err)
}
return n, nil
}
return 0, fmt.Errorf("unsupported updated_at type %T at %q", v, s.UpdatedAtField)
}