Files
2026-08-23 13:43:40 +02:00

213 lines
9.9 KiB
Go

// Schema declarations and the DefaultSchema consensus shape.
//
// A Schema maps a producer's private source JSON to the platform-wide
// Projection. Field names point at keys in the source map; Value coercions
// happen in the Builder. A Schema is the unit of configuration per tenant;
// the Registry (registry.go) carries the per-tenant map.
package catalog
// UpdatedAtSource controls where the Projection's UpdatedAt comes from.
//
// - StampAtPublish — Builder sets UpdatedAt to time.Now().UnixMilli() at the
// moment Build() runs. RESERVED as a deliberate-fallback for sources
// that genuinely have no other version stamp. NOT a content-version
// address: a full snapshot rebuild re-stamps every projection and
// invalidates every consumer cache even when nothing changed. Use only
// when the producer side has NO source mtime / hash to set.
//
// - UseSourceField — the production-correct choice. Builder parses
// source[Schema.UpdatedAtField] as a Unix-millisecond integer (or
// decimal-fraction-of-Unix-second if the source uses seconds — Apply
// accepts that and converts). This is the content-version value;
// rebuilds produce identical UpdatedAt when content is identical.
//
// Critical contract: UpdatedAt is the CONTENT VERSION, not the publish
// timestamp. Consumers use it for snapshot + revalidate. If your schema
// sets UpdatedAtSource=StampAtPublish, every snapshot rebuild churns the
// version. Choose UseSourceField for any consumer-cache scenario.
type UpdatedAtSource int
const (
// StampAtPublish sets UpdatedAt at Build() time. DefaultSchema
// deliberately does NOT use this; it's reserved for callers who know
// they want publish-time semantics.
StampAtPublish UpdatedAtSource = iota
// UseSourceField reads the version from Schema.UpdatedAtField. This is
// the contract for content-versioned snapshots.
UseSourceField
)
// Schema is the declarative per-tenant mapping from a producer's source JSON
// to the published Projection. Zero value is NOT valid — always start from
// DefaultSchema() and override what differs, so unset-but-required fields
// surface immediately rather than silently emitting empty Projections.
//
// All "Field" strings refer to keys in the source map[string]any that the
// producer hands the Builder. Empty Field means "this Projection carrier is
// supplied by Fixed* / Default* / Build-time, not read from source".
type Schema struct {
// Tenant is the registry key this schema is registered under. Empty
// means "default" — see Registry.
Tenant string `json:"tenant,omitempty"`
// --- source field references (per carrier) ---
SKUField string `json:"sku_field"`
TitleField string `json:"title_field,omitempty"`
PriceField string `json:"price_field"`
CurrencyField string `json:"currency_field,omitempty"`
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"`
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
// should be extracted and built into the Projection's json.RawMessage Attributes field.
AttributesFields []string `json:"attributes_fields,omitempty"`
// ItemIdField is the source-side key carrying the producer's catalog
// item id (uint32-equivalent). DefaultSchema points at "id" (matching
// the product service's /api/get/{sku} shape). Producers that omit
// an integer id field simply leave ItemIdField empty — consumers see
// Projection.ItemID=0 and fall back to their HTTP lookup.
ItemIdField string `json:"item_id_field,omitempty"`
// --- fixed / default values used when a source field is absent ---.
FixedCurrency string `json:"fixed_currency,omitempty"`
// DefaultTaxClass is what the Builder writes when the source has no
// TaxClass field (e.g. producer doesn't classify). Empty means "leave
// empty" — consumer's tax.Provider gets called with empty string.
DefaultTaxClass string `json:"default_tax_class,omitempty"`
// --- derived-value controls ---
// PriceScale: source value interpreted as MINOR units if ≤ 1 (pass
// through; floats round half-away-from-zero, ints pass through exact),
// MAJOR units if 100 (multiply by 100 to get øre), etc. Negative =
// rejected. The Builder rounds half-away-from-zero on fractional
// results so the integer Projection never silently truncates.
PriceScale int `json:"price_scale"`
// IDPrefix is prepended to the normalised ID. Use "sku:" for the
// consensus convention; an empty prefix means raw SKU.
IDPrefix string `json:"id_prefix,omitempty"`
// UpdatedAtSource — see type doc. DefaultSchema uses UseSourceField and
// pins UpdatedAtField="updated_at". Producers MUST supply an updated_at
// value (Unix-ms int); Apply returns ErrProjectionBuild (wrapped) when
// it's missing rather than silently stamp-now'ing and churning every
// rebuild. StampAtPublish is RESERVED for sources with no other stamp.
UpdatedAtSource UpdatedAtSource `json:"updated_at_source"`
// UpdatedAtField is the SOURCE-side key (when UpdatedAtSource ==
// UseSourceField) the Builder reads the version from.
UpdatedAtField string `json:"updated_at_field,omitempty"`
// TrueValues is the set of source values that map to InventoryTracked
// / DropShip = true. Comparison is lower-cased. Defaults to
// DefaultTrueValues below if left nil — never add "false" / "0" etc,
// the Builder treats anything not in TrueValues as false.
TrueValues []string `json:"true_values,omitempty"`
}
// DefaultSchema is the consensus schema: SKU under "sku", price under
// "price_inc_vat" already in øre, currency from source "currency" or empty,
// tax_class from "tax_class", inventory_tracked / drop_ship from
// "inventory_tracked" / "drop_ship" bool fields. updated_at MUST come from
// the source under the field name "updated_at" (Unix-ms) — Apply rejects
// missing values loudly rather than silent stamp-now churn.
//
// Use this when a tenant has no override file; it is the safe default for
// producers that already emit platform-shaped JSON.
func DefaultSchema() Schema {
return Schema{
Tenant: "default",
// source-side field names
SKUField: "sku",
TitleField: "title",
PriceField: "price_inc_vat",
CurrencyField: "currency",
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",
Category4Field: "category4",
Category5Field: "category5",
BrandField: "brand",
ImageField: "image",
// Standard product service carries the integer catalog id under "id".
ItemIdField: "id",
UpdatedAtField: "updated_at",
AttributesFields: []string{"data"},
FixedCurrency: "", // read from source if present
DefaultTaxClass: "standard",
PriceScale: 1, // price_inc_vat already in minor units
IDPrefix: "sku:",
UpdatedAtSource: UseSourceField, // change to StampAtPublish only with intent
TrueValues: DefaultTrueValues(),
}
}
// BygglagretSchema is an explicit preset for the Swedish building-supplies
// tenant that ships inc-VAT float prices in a "PriceInclVAT" string field.
//
// CRITICAL: TaxClassField (VATClass) is a STRING CLASS IDENTIFIER, not a raw
// percent. The producer MUST pre-classify numeric VAT codes ("25" →
// "standard", "12" → "reduced-12", "6" → "reduced-6") before publishing.
// The wire contract is class identifiers; platform/tax resolves them to
// numeric rates out of band. Shipping "25" as the class would break that
// resolution path on the consumer side.
//
// ItemIdField: bygglagret rows carry the integer item id under "item_id"
// (matches the per-row log shape we see from the upstream product import).
func BygglagretSchema() Schema {
return Schema{
Tenant: "bygglagret",
SKUField: "ArticleNumber",
TitleField: "Name",
PriceField: "PriceInclVAT",
CurrencyField: "", // fixed below
TaxClassField: "VATClass",
InventoryTrackedF: "StockTracked",
DropShipF: "DropshipFlag",
CategoryField: "CategoryPath",
Category2Field: "Category2",
Category3Field: "Category3",
Category4Field: "Category4",
Category5Field: "Category5",
BrandField: "Brand",
ImageField: "PrimaryImage",
ItemIdField: "item_id",
UpdatedAtField: "LastModifiedMs",
AttributesFields: []string{"data"},
FixedCurrency: "SEK",
DefaultTaxClass: "standard",
PriceScale: 100, // PriceInclVAT is in SEK; multiply for øre
IDPrefix: "sku:",
UpdatedAtSource: UseSourceField,
TrueValues: DefaultTrueValues(),
}
}
// DefaultTrueValues is the consensus truthy-string set for bool-typed
// source fields. Lower-cased comparison in Builder.
func DefaultTrueValues() []string {
return []string{"true", "yes", "1", "on"}
}