update
This commit is contained in:
1 parent
540aa48c44
commit
bd81f4ff54
8 files changed
+322
-4
No files matched your search
@@ -144,6 +144,10 @@ func Apply(s Schema, source map[string]any) (Projection, error) {
|
||||
InventoryTracked: boolOf(source, s.InventoryTrackedF, truthy),
|
||||
DropShip: boolOf(source, s.DropShipF, truthy),
|
||||
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,
|
||||
|
||||
+12
-1
@@ -19,6 +19,10 @@
|
||||
// inventory_tracked ← bool coercion of source[InventoryTrackedField]
|
||||
// drop_ship ← bool coercion of source[DropShipField]
|
||||
// category ← source[CategoryField] (omitempty)
|
||||
// category2 ← source[Category2Field] (omitempty)
|
||||
// category3 ← source[Category3Field] (omitempty)
|
||||
// category4 ← source[Category4Field] (omitempty)
|
||||
// category5 ← source[Category5Field] (omitempty)
|
||||
// brand ← source[BrandField] (omitempty)
|
||||
// image ← source[ImageField] (omitempty)
|
||||
// updated_at ← Unix-ms from source[UpdatedAtField] OR stamped at
|
||||
@@ -75,7 +79,10 @@ import (
|
||||
// asks for these so the cart can pre-mark drop-ship
|
||||
// items before checkout and inventory can opt-out of
|
||||
// tracking for non-stock items.
|
||||
// Category / Brand / Image — display projection only.
|
||||
// Category* / Brand / Image — display projection only.
|
||||
// Category2–Category5 extend category with deeper taxonomy levels for
|
||||
// promotions engine multi-level matching and tracking personas.
|
||||
// Each level is optional; an empty string means "not published".
|
||||
// UpdatedAt — version stamp for snapshot + revalidate.
|
||||
//
|
||||
// WIRE-COMPAT: adding a field is forward+backward-safe. Old snapshot
|
||||
@@ -97,6 +104,10 @@ type Projection struct {
|
||||
InventoryTracked bool `json:"inventory_tracked"`
|
||||
DropShip bool `json:"drop_ship"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Category2 string `json:"category2,omitempty"`
|
||||
Category3 string `json:"category3,omitempty"`
|
||||
Category4 string `json:"category4,omitempty"`
|
||||
Category5 string `json:"category5,omitempty"`
|
||||
Brand string `json:"brand,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
|
||||
@@ -59,6 +59,10 @@ type Schema struct {
|
||||
InventoryTrackedF string `json:"inventory_tracked_field,omitempty"`
|
||||
DropShipF string `json:"drop_ship_field,omitempty"`
|
||||
CategoryField string `json:"category_field,omitempty"`
|
||||
Category2Field string `json:"category2_field,omitempty"`
|
||||
Category3Field string `json:"category3_field,omitempty"`
|
||||
Category4Field string `json:"category4_field,omitempty"`
|
||||
Category5Field string `json:"category5_field,omitempty"`
|
||||
BrandField string `json:"brand_field,omitempty"`
|
||||
ImageField string `json:"image_field,omitempty"`
|
||||
// AttributesFields names the keys in the source map[string]any that
|
||||
@@ -125,6 +129,10 @@ func DefaultSchema() Schema {
|
||||
InventoryTrackedF: "inventory_tracked",
|
||||
DropShipF: "drop_ship",
|
||||
CategoryField: "category",
|
||||
Category2Field: "category2",
|
||||
Category3Field: "category3",
|
||||
Category4Field: "category4",
|
||||
Category5Field: "category5",
|
||||
BrandField: "brand",
|
||||
ImageField: "image",
|
||||
// Standard product service carries the integer catalog id under "id".
|
||||
@@ -165,6 +173,10 @@ func BygglagretSchema() Schema {
|
||||
InventoryTrackedF: "StockTracked",
|
||||
DropShipF: "DropshipFlag",
|
||||
CategoryField: "CategoryPath",
|
||||
Category2Field: "Category2",
|
||||
Category3Field: "Category3",
|
||||
Category4Field: "Category4",
|
||||
Category5Field: "Category5",
|
||||
BrandField: "Brand",
|
||||
ImageField: "PrimaryImage",
|
||||
ItemIdField: "item_id",
|
||||
|
||||
@@ -104,6 +104,18 @@ func SynthItem(s catalog.Schema, rng *rand.Rand, i int, skuPrefix string) map[st
|
||||
if s.CategoryField != "" {
|
||||
m[s.CategoryField] = fmt.Sprintf("c-%d", rng.Intn(50))
|
||||
}
|
||||
if s.Category2Field != "" {
|
||||
m[s.Category2Field] = fmt.Sprintf("c2-%d", rng.Intn(20))
|
||||
}
|
||||
if s.Category3Field != "" {
|
||||
m[s.Category3Field] = fmt.Sprintf("c3-%d", rng.Intn(10))
|
||||
}
|
||||
if s.Category4Field != "" {
|
||||
m[s.Category4Field] = fmt.Sprintf("c4-%d", rng.Intn(5))
|
||||
}
|
||||
if s.Category5Field != "" {
|
||||
m[s.Category5Field] = fmt.Sprintf("c5-%d", rng.Intn(3))
|
||||
}
|
||||
if s.BrandField != "" {
|
||||
m[s.BrandField] = "Acme"
|
||||
}
|
||||
|
||||
+5
-2
@@ -42,8 +42,11 @@ type Created struct {
|
||||
Country string `json:"country"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
CustomerEmail string `json:"customer_email,omitempty"`
|
||||
Lines []Line `json:"lines"`
|
||||
PlacedAtMs int64 `json:"placed_at_ms,omitempty"`
|
||||
// CustomerID is the customer profile ID for identity linking (B0.4). Reactors
|
||||
// (e.g. profile service) use this to link the order to the customer profile.
|
||||
CustomerID string `json:"customer_id,omitempty"`
|
||||
Lines []Line `json:"lines"`
|
||||
PlacedAtMs int64 `json:"placed_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// Encode serialises the payload for event.Event.Payload.
|
||||
|
||||
+11
-1
@@ -84,7 +84,17 @@ func Dial(url, name string) (*Conn, error) {
|
||||
done: make(chan struct{}),
|
||||
onReconn: make(chan struct{}),
|
||||
}
|
||||
raw, err := dial(url, name)
|
||||
var raw *amqp.Connection
|
||||
var err error
|
||||
for attempts := 0; attempts < 10; attempts++ {
|
||||
raw, err = dial(url, name)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempts < 9 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package region provides a first-class region configuration for the commerce
|
||||
// platform. It maps country codes to their associated currency, locale, and
|
||||
// other market-specific settings, replacing the hardcoded getCurrency/getLocale
|
||||
// helpers that were duplicated across services.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// cfg, err := region.LoadConfig("data/regions.json")
|
||||
// currency := cfg.CurrencyOf("no") // "NOK"
|
||||
// locale := cfg.LocaleOf("no") // "nb-no"
|
||||
// country := cfg.CountryFromCurrency("NOK") // "no"
|
||||
package region
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Region defines the market configuration for a single country.
|
||||
type Region struct {
|
||||
CountryCode string `json:"countryCode"` // ISO 3166-1 alpha-2 lowercase, e.g. "se", "no", "dk", "fi"
|
||||
Currency string `json:"currency"` // ISO 4217, e.g. "SEK", "NOK", "DKK", "EUR"
|
||||
Locale string `json:"locale"` // IETF BCP 47, e.g. "sv-se", "nb-no", "da-dk", "fi-fi"
|
||||
}
|
||||
|
||||
// Config holds the full set of region definitions.
|
||||
type Config struct {
|
||||
Regions []Region `json:"regions"`
|
||||
}
|
||||
|
||||
// LoadConfig reads a region configuration from a JSON file. An absent file
|
||||
// returns a default Config with only SE configured, matching the legacy
|
||||
// hardcoded behaviour.
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("region: open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var cfg Config
|
||||
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("region: decode %s: %w", path, err)
|
||||
}
|
||||
// Normalise country codes to lowercase.
|
||||
for i := range cfg.Regions {
|
||||
cfg.Regions[i].CountryCode = strings.ToLower(cfg.Regions[i].CountryCode)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// DefaultConfig returns the built-in default with SE and NO configured.
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Regions: []Region{
|
||||
{CountryCode: "se", Currency: "SEK", Locale: "sv-se"},
|
||||
{CountryCode: "no", Currency: "NOK", Locale: "nb-no"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CurrencyOf returns the ISO 4217 currency code for the given country.
|
||||
// Returns "SEK" if the country is not configured.
|
||||
func (c *Config) CurrencyOf(country string) string {
|
||||
cc := strings.ToLower(country)
|
||||
for _, r := range c.Regions {
|
||||
if r.CountryCode == cc {
|
||||
return r.Currency
|
||||
}
|
||||
}
|
||||
return "SEK"
|
||||
}
|
||||
|
||||
// LocaleOf returns the IETF BCP 47 locale tag for the given country.
|
||||
// Returns "sv-se" if the country is not configured.
|
||||
func (c *Config) LocaleOf(country string) string {
|
||||
cc := strings.ToLower(country)
|
||||
for _, r := range c.Regions {
|
||||
if r.CountryCode == cc {
|
||||
return r.Locale
|
||||
}
|
||||
}
|
||||
return "sv-se"
|
||||
}
|
||||
|
||||
// CountryFromCurrency is the reverse of CurrencyOf — it returns the country
|
||||
// code that uses the given currency. Returns "se" for unknown currencies.
|
||||
func (c *Config) CountryFromCurrency(currency string) string {
|
||||
curr := strings.ToUpper(currency)
|
||||
for _, r := range c.Regions {
|
||||
if strings.EqualFold(r.Currency, curr) {
|
||||
return r.CountryCode
|
||||
}
|
||||
}
|
||||
return "se"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if len(cfg.Regions) != 2 {
|
||||
t.Fatalf("expected 2 default regions, got %d", len(cfg.Regions))
|
||||
}
|
||||
|
||||
if got := cfg.CurrencyOf("se"); got != "SEK" {
|
||||
t.Errorf("CurrencyOf(se) = %s, want SEK", got)
|
||||
}
|
||||
if got := cfg.LocaleOf("se"); got != "sv-se" {
|
||||
t.Errorf("LocaleOf(se) = %s, want sv-se", got)
|
||||
}
|
||||
if got := cfg.CurrencyOf("no"); got != "NOK" {
|
||||
t.Errorf("CurrencyOf(no) = %s, want NOK", got)
|
||||
}
|
||||
if got := cfg.LocaleOf("no"); got != "nb-no" {
|
||||
t.Errorf("LocaleOf(no) = %s, want nb-no", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyOf(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
tests := []struct {
|
||||
country string
|
||||
want string
|
||||
}{
|
||||
{"se", "SEK"},
|
||||
{"SE", "SEK"},
|
||||
{"no", "NOK"},
|
||||
{"NO", "NOK"},
|
||||
{"dk", "SEK"}, // unknown → default
|
||||
{"fi", "SEK"}, // unknown → default
|
||||
{"us", "SEK"}, // unknown → default
|
||||
{"", "SEK"}, // empty → default
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.country, func(t *testing.T) {
|
||||
if got := cfg.CurrencyOf(tt.country); got != tt.want {
|
||||
t.Errorf("CurrencyOf(%q) = %q, want %q", tt.country, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleOf(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
tests := []struct {
|
||||
country string
|
||||
want string
|
||||
}{
|
||||
{"se", "sv-se"},
|
||||
{"SE", "sv-se"},
|
||||
{"no", "nb-no"},
|
||||
{"NO", "nb-no"},
|
||||
{"dk", "sv-se"}, // unknown → default
|
||||
{"", "sv-se"}, // empty → default
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.country, func(t *testing.T) {
|
||||
if got := cfg.LocaleOf(tt.country); got != tt.want {
|
||||
t.Errorf("LocaleOf(%q) = %q, want %q", tt.country, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryFromCurrency(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
tests := []struct {
|
||||
currency string
|
||||
want string
|
||||
}{
|
||||
{"SEK", "se"},
|
||||
{"sek", "se"},
|
||||
{"NOK", "no"},
|
||||
{"nok", "no"},
|
||||
{"DKK", "se"}, // unknown → default
|
||||
{"EUR", "se"}, // unknown → default
|
||||
{"", "se"}, // empty → default
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.currency, func(t *testing.T) {
|
||||
if got := cfg.CountryFromCurrency(tt.currency); got != tt.want {
|
||||
t.Errorf("CountryFromCurrency(%q) = %q, want %q", tt.currency, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_FileNotFound(t *testing.T) {
|
||||
cfg, err := LoadConfig("/nonexistent/regions.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig on missing file should not error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config on missing file")
|
||||
}
|
||||
if len(cfg.Regions) != 2 {
|
||||
t.Errorf("expected 2 default regions, got %d", len(cfg.Regions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_CustomFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "regions.json")
|
||||
content := `{
|
||||
"regions": [
|
||||
{"countryCode": "se", "currency": "SEK", "locale": "sv-se"},
|
||||
{"countryCode": "no", "currency": "NOK", "locale": "nb-no"},
|
||||
{"countryCode": "dk", "currency": "DKK", "locale": "da-dk"}
|
||||
]
|
||||
}`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
if len(cfg.Regions) != 3 {
|
||||
t.Fatalf("expected 3 regions, got %d", len(cfg.Regions))
|
||||
}
|
||||
|
||||
if got := cfg.CurrencyOf("dk"); got != "DKK" {
|
||||
t.Errorf("CurrencyOf(dk) = %s, want DKK", got)
|
||||
}
|
||||
if got := cfg.LocaleOf("dk"); got != "da-dk" {
|
||||
t.Errorf("LocaleOf(dk) = %s, want da-dk", got)
|
||||
}
|
||||
if got := cfg.CountryFromCurrency("DKK"); got != "dk" {
|
||||
t.Errorf("CountryFromCurrency(DKK) = %s, want dk", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_NormalisesCountryCode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "regions.json")
|
||||
content := `{"regions": [{"countryCode": "NO", "currency": "NOK", "locale": "nb-no"}]}`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
if got := cfg.CurrencyOf("NO"); got != "NOK" {
|
||||
t.Errorf("CurrencyOf(NO) = %s, want NOK", got)
|
||||
}
|
||||
if got := cfg.CurrencyOf("no"); got != "NOK" {
|
||||
t.Errorf("CurrencyOf(no) = %s, want NOK", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user