diff --git a/catalog/builder.go b/catalog/builder.go index f7a19f7..fe046b5 100644 --- a/catalog/builder.go +++ b/catalog/builder.go @@ -143,6 +143,9 @@ func Apply(s Schema, source map[string]any) (Projection, error) { 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), @@ -260,11 +263,57 @@ func uint32Of(m map[string]any, key string) uint32 { 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 == "" { +func boolOf(m map[string]any, key string, truthy []string) bool { if key == "" { return false } v, ok := m[key] diff --git a/catalog/builder_test.go b/catalog/builder_test.go index 8755977..5635551 100644 --- a/catalog/builder_test.go +++ b/catalog/builder_test.go @@ -15,12 +15,12 @@ import ( func TestBuilderJSONNumber(t *testing.T) { // json.Number for SKU src := map[string]any{ - "sku": json.Number("123456789012345"), // float64-unsafe - "title": "BigNumber SKU", - "price_inc_vat": json.Number("14995"), // already øre - "currency": "SEK", - "tax_class": "standard", - "updated_at": json.Number("1700000000000"), + "sku": json.Number("123456789012345"), // float64-unsafe + "title": "BigNumber SKU", + "price_inc_vat": json.Number("14995"), // already øre + "currency": "SEK", + "tax_class": "standard", + "updated_at": json.Number("1700000000000"), } p, err := Apply(DefaultSchema(), src) if err != nil { @@ -193,3 +193,99 @@ func TestBuilderFractionalStringPriceCorrectScale(t *testing.T) { t.Errorf("PriceIncVat: got %d want %d", p.PriceIncVat, 14995) } } + +// Drop-ship routing carriers (docs/plan-dropship.md Phase 1): seller id / +// name / lead time coerce from per-tenant source keys via Schema overrides, +// mirroring how the live feed emits supplierName-style keys. Zero values +// (missing keys, negative numbers, junk strings) must stay at the documented +// "unset" signals rather than erroring — one bad item can't poison a batch. +func TestBuilderDropshipCarriers(t *testing.T) { + s := DefaultSchema() + s.SellerIdField = "supplierId" + s.SellerNameField = "supplierName" + s.LeadTimeField = "deliveryTime" + + p, err := Apply(s, map[string]any{ + "sku": "X-1", + "price_inc_vat": int64(100_00), + "updated_at": int64(1700000000000), + "drop_ship": true, + "supplierId": 4711, + "supplierName": "Bygg Import AB", + "deliveryTime": 3, + }) + if err != nil { + t.Fatalf("apply with dropship carriers: %v", err) + } + if !p.DropShip { + t.Error("DropShip: got false, want true") + } + if p.SellerID != 4711 { + t.Errorf("SellerID: got %d, want 4711", p.SellerID) + } + if p.SellerName != "Bygg Import AB" { + t.Errorf("SellerName: got %q", p.SellerName) + } + if p.LeadTimeDays != 3 { + t.Errorf("LeadTimeDays: got %d, want 3", p.LeadTimeDays) + } + + // Missing keys → unset signals, no error. + p, err = Apply(s, map[string]any{ + "sku": "X-2", + "price_inc_vat": int64(100_00), + "updated_at": int64(1700000000000), + }) + if err != nil { + t.Fatalf("apply without dropship carriers: %v", err) + } + if p.DropShip || p.SellerID != 0 || p.SellerName != "" || p.LeadTimeDays != 0 { + t.Errorf("unset carriers should zero: %+v", p) + } + + // Junk shapes floor to unset, never fail the build. + for name, bad := range map[string]any{ + "negative id": -5, + "string id": "not-a-number", + "float id": 1.5, + } { + p, err = Apply(s, map[string]any{ + "sku": "X-3", "price_inc_vat": int64(100_00), + "updated_at": int64(1700000000000), + "supplierId": bad, + }) + if err != nil { + t.Fatalf("%s: apply should not fail on junk carrier: %v", name, err) + } + if p.SellerID != 0 { + t.Errorf("%s: SellerID should be 0, got %d", name, p.SellerID) + } + } + + // Wire round-trip: new fields survive Encode/Decode for consumers that + // never rebuilt. + b, err := p.Encode() + if err != nil { + t.Fatalf("encode: %v", err) + } + rt, err := Decode(b) + if err != nil { + t.Fatalf("decode: %v", err) + } + if rt.SKU != p.SKU || rt.DropShip || rt.SellerID != 0 { + t.Errorf("round-trip mismatch: %+v vs %+v", rt, p) + } + + // json.Number lead time (UseNumber producers). + p, err = Apply(s, map[string]any{ + "sku": "X-4", "price_inc_vat": int64(100_00), + "updated_at": int64(1700000000000), + "deliveryTime": json.Number("7"), + }) + if err != nil { + t.Fatalf("json.Number lead time: %v", err) + } + if p.LeadTimeDays != 7 { + t.Errorf("LeadTimeDays from json.Number: got %d, want 7", p.LeadTimeDays) + } +} diff --git a/catalog/projection.go b/catalog/projection.go index 27fe067..42eb7d2 100644 --- a/catalog/projection.go +++ b/catalog/projection.go @@ -18,6 +18,9 @@ // tax_class ← source[TaxClassField] (string identifier) // inventory_tracked ← bool coercion of source[InventoryTrackedField] // drop_ship ← bool coercion of source[DropShipField] +// seller_id ← uint32 coercion of source[SellerIdField] (0 = unset) +// seller_name ← source[SellerNameField] (omitempty) +// lead_time_days ← int coercion of source[LeadTimeField] (0 = unset) // category ← source[CategoryField] (omitempty) // category2 ← source[Category2Field] (omitempty) // category3 ← source[Category3Field] (omitempty) @@ -103,6 +106,15 @@ type Projection struct { TaxClass string `json:"tax_class"` InventoryTracked bool `json:"inventory_tracked"` DropShip bool `json:"drop_ship"` + // Drop-ship routing carriers (Phase 1 of docs/plan-dropship.md). + // SellerID mirrors AddItem.ItemId coercion rules: 0 = "producer didn't + // publish" and downstream keeps whatever the HTTP fallback fetched. + // SellerName is what checkout writes into order-line + // Properties["supplier"] (the dropship-fulfill flow groups on it). + // LeadTimeDays is supplier lead time in days, 0 = unset. + SellerID uint32 `json:"seller_id,omitempty"` + SellerName string `json:"seller_name,omitempty"` + LeadTimeDays int `json:"lead_time_days,omitempty"` Category string `json:"category,omitempty"` Category2 string `json:"category2,omitempty"` Category3 string `json:"category3,omitempty"` diff --git a/catalog/schema.go b/catalog/schema.go index 2c0659a..159cfd6 100644 --- a/catalog/schema.go +++ b/catalog/schema.go @@ -58,6 +58,15 @@ type Schema struct { TaxClassField string `json:"tax_class_field,omitempty"` InventoryTrackedF string `json:"inventory_tracked_field,omitempty"` DropShipF string `json:"drop_ship_field,omitempty"` + // SellerIdField / SellerNameField name the source keys carrying the + // drop-ship supplier identity. SellerId coerces to uint32 (0 = unset); + // SellerName is the human-facing name checkout writes into order-line + // Properties["supplier"]. Empty field = carrier not published. + SellerIdField string `json:"seller_id_field,omitempty"` + SellerNameField string `json:"seller_name_field,omitempty"` + // LeadTimeField names the source key carrying supplier lead time in + // days (numeric; fractional values floor). Empty = not published. + LeadTimeField string `json:"lead_time_field,omitempty"` CategoryField string `json:"category_field,omitempty"` Category2Field string `json:"category2_field,omitempty"` Category3Field string `json:"category3_field,omitempty"` @@ -128,6 +137,9 @@ func DefaultSchema() Schema { TaxClassField: "tax_class", InventoryTrackedF: "inventory_tracked", DropShipF: "drop_ship", + SellerIdField: "seller_id", + SellerNameField: "seller_name", + LeadTimeField: "lead_time_days", CategoryField: "category", Category2Field: "category2", Category3Field: "category3",