Files
platform/catalog/projection.go
T
2026-08-27 15:50:09 +02:00

202 lines
10 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.
// Package catalog — see doc.go for the package summary.
//
// This file holds the published Projection struct: the wire shape that
// commerce modules receive. Every producer adapter (writeradmin, future CMS
// catalog adapter, ...) emits this exact shape; every consumer (cart,
// checkout, order, UCP add-to-cart) reads from this exact shape. Per-tenant
// field remapping happens in Schema + Builder (schema.go, builder.go).
//
// Mapping source JSON → Projection is per-tenant and lives in Schema.
// The consensus mapping for DefaultSchema() is:
//
// item_id ← int coercion of source[ItemIdField] (omitempty; 0 = unset)
// id ← IDPrefix + SKU (e.g. "sku:<SKU>")
// sku ← source[SKUField]
// title ← source[TitleField] (omitempty, optional)
// price_inc_vat ← coercive parse of source[PriceField] × PriceScale
// currency ← source[CurrencyField], or FixedCurrency
// 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)
// 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
// publish time when UpdatedAtSource == StampAtPublish
//
// SCALE (rate-bearing fields): any field that carries a tax rate uses the
// platform-wide basis-points-of-a-percent convention (rate × 100; 25% = 2500,
// 12.5% = 1250, 8.1% = 810). Never float. Producer-side fields stay in
// minor currency units (öre / cents) for amounts — already int64. TaxClass
// on the wire is a STRING identifier; numeric rate resolution is a platform/tax
// concern (out of package scope by design).
package catalog
import (
"encoding/json"
"git.k6n.net/mats/platform/money"
"git.k6n.net/mats/platform/projection"
)
// Projection is the published, versioned catalog entry that commerce modules
// consume. Finder owns updating it; cart / checkout / order read it.
//
// Versioning: UpdatedAt is a monotonic Unix-millisecond timestamp stamped by
// the producer at the time the AMQP event is emitted (or read from a
// designated source field, see Schema.UpdatedAtSource). Consumers, when
// revalidating, treat a strictly greater UpdatedAt as the authoritative
// replacement and refresh the local copy.
//
// Field rationale:
//
// ItemID — opaque numeric catalog item id from the producer's
// source (e.g. product service `/api/get/{sku}` integer
// `id` field, or the bygglagret raw row `item_id`).
// Carried here as uint32 to mirror go-cart-actor's
// proto messages.AddItem.ItemId which dedupes cart lines
// on equality. Zero means "producer didn't publish" — a
// legitimate value when the source has no integer id
// (the cart fall-back path uses HTTP PRODUCT_BASE_URL
// to recover the id in that case).
// ID — opaque published string identity; SKU alone is not a
// stable cross-module id (different scopes reuse it).
// SKU — domain identifier; the join key cart already uses.
// PriceIncVat — display + checkout projection; NOT transactional truth.
// System of record for the total is settled via the
// checkout/order payload — see platform-uniforming-plan.md.
// Currency — optional but recommended; absent ⇒ caller supplies.
// TaxClass — class identifier (string). Numeric rate resolution is
// a platform/tax concern (see platform/tax.Provider). This
// struct never carries a numeric rate; that keeps the
// rate-scale convention in one place and keeps the wire
// free of consumer-policy values.
// InventoryTracked / DropShip — fulfillment-routing flags. The C1 contract
// 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.
// 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
// readers ignore unknown JSON keys; new readers populate the field from
// the JSON token. The on-disk snapshot binary header/record layout is
// unchanged \u2014 only the per-blob JSON payload gains a key.
type Projection struct {
ItemID uint32 `json:"item_id,omitempty"`
ID string `json:"id"`
SKU string `json:"sku"`
Title string `json:"title,omitempty"`
PriceIncVat money.Cents `json:"price_inc_vat"`
Currency string `json:"currency,omitempty"`
// TaxClass uses a string identifier (see platform/tax class constants).
// Empty string means "unclassified"; the consumer's tax.Provider is what
// resolves the identifier to a numeric rate.
Attributes json.RawMessage `json:"attributes,omitempty"`
TaxClass string `json:"tax_class"`
InventoryTracked bool `json:"inventory_tracked"`
DropShip bool `json:"drop_ship"`
// Drop-ship routing carriers (Phase 1 of docs/archive/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"`
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"`
}
// PayloadKey is the envelope key producers use when publishing a slice of
// projections over the bus as an `event.Event.Payload` body. Consumers parse
// this exact key — never a per-producer ad-hoc name — so the wire shape is
// stable.
const PayloadKey = "catalog_projection"
// Encode serialises a Projection to JSON suitable for the event.Payload.
// Kept as a package-level helper so producers and tests share the exact wire
// shape; any future wire renaming lands here, not in every caller.
func (p Projection) Encode() ([]byte, error) {
return json.Marshal(p)
}
// EncodeBatch serialises a slice to a JSON array.
func EncodeBatch(batch []Projection) ([]byte, error) {
return json.Marshal(batch)
}
// ProjectionUpdate is one entry in a CatalogProjectionPublished batch. An
// upsert carries a fully-built Projection. A delete sets Deleted=true; only SKU
// (and ID) are then meaningful and the consumer drops that SKU from its
// snapshot / cache. Modelling delete as a flag (not a nil payload) keeps a
// change-set's upserts and deletes together in one ordered event.
type ProjectionUpdate struct {
Projection
Deleted bool `json:"deleted,omitempty"`
}
// EncodeUpdates serialises a batch for the event.Event.Payload body.
func EncodeUpdates(batch []ProjectionUpdate) ([]byte, error) {
return json.Marshal(batch)
}
// DecodeUpdates parses a CatalogProjectionPublished payload — the consumer side
// of EncodeUpdates.
func DecodeUpdates(raw []byte) ([]ProjectionUpdate, error) {
var out []ProjectionUpdate
err := json.Unmarshal(raw, &out)
return out, err
}
// Decode parses a Projection from JSON. Symmetric with Encode so consumer
// tests can round-trip a fixture without depending on the Builder.
func Decode(raw []byte) (Projection, error) {
var p Projection
err := json.Unmarshal(raw, &p)
return p, err
}
// ── Bus framing contract (shared by producer + consumer) ─────────────────────
//
// catalog.projection_published carries two channels distinguished only by
// event.Event.Meta. The producer (slask-finder writeradmin) sets these keys and
// the consumer (ProjectionStore, consumer.go) reads them — one definition so the
// two never drift. The framing itself is domain-neutral and lives in
// platform/projection; catalog re-exports the constants so the producer/consumer
// keep their catalog.* names. See docs/catalog-projection.md.
//
// - A FULL SNAPSHOT is a framed begin → chunk* → end stream, all tagged with one
// MetaEpoch. begin/end carry MetaCount (total projections) for completeness
// validation; chunks carry the projections.
// - A DELTA has NO MetaSnapshotPhase key, and MetaEpoch names the snapshot base
// it builds on (consumers ignore a delta older than the base they hold).
const (
MetaSnapshotPhase = projection.MetaSnapshotPhase // begin|chunk|end; absent ⇒ delta
MetaEpoch = projection.MetaEpoch // int64 (Unix-ms), monotonic across rebuilds
MetaSeq = projection.MetaSeq // chunk sequence within a snapshot
MetaCount = projection.MetaCount // total projections (begin/end only)
MetaTenant = projection.MetaTenant // namespace tag
SnapshotBegin = projection.SnapshotBegin
SnapshotChunk = projection.SnapshotChunk
SnapshotEnd = projection.SnapshotEnd
)