Scan-to-order: label from the customer's camera to "buy it here"

POST /v1/mob/scan/lookup   label + customer → catalogue match, sizes, and
                           every registered store that sells it with live
                           stock, in-stock first / nearest first, one
                           recommended
POST /v1/mob/scan/confirm  chosen store + size + qty → re-read the ledger;
                           ok, or the next-nearest store with enough of the
                           same product
GET  /v1/mob/scan/stores   registered stores nearest first

Recognition is pgvector cosine search over every brand_* table (each
with its own index, merged) plus a word match that settles near-ties
and works alone when no model is configured. The embedder is chosen by
EMBEDDING_PROVIDER (OpenAI-compatible or Gemini) and must be the model
that indexed the catalogue: verified 2026-09-15 as all-MiniLM-L6-v2 over
search_query, served by the cluster's Ollama as `all-minilm`; the first
search refuses a width mismatch by name.

Customer, stores and catalogue are read concurrently under a 5 s cap; a
slow model degrades to a text answer. Vectors and ranked hits are cached
in Redis and in-process; live stock never is. Availability uses the same
rules as the customer catalogue (approve, publishedat, ledger balance,
outlet price else retail). No stock reservation: confirm re-reads.

scratch/cataloguedims reports the catalogue's embedding width and fill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 17:04:34 +05:30
parent 1633617dc4
commit 72907dae74
14 changed files with 2841 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ import (
"nearle/controllers"
"nearle/repositories"
"nearle/services"
"nearle/utils"
"gorm.io/gorm"
)
@@ -22,6 +23,7 @@ type Facade struct {
PosController *controllers.PosController
LiveController *controllers.LiveController
CatalogueUploadController *controllers.CatalogueUploadController
ScanController *controllers.ScanController
// Held so the NATS consumer can reach the ingest without going through
// HTTP. Unexported: everything else should use the controller.
@@ -32,7 +34,8 @@ type Facade struct {
// catalogueDB is a separate connection to the pgvector catalogue database;
// it may be nil if catalogue env vars are not configured, in which case
// catalogue endpoints will error at query time rather than at startup.
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
// embedder may be nil too: scan-to-order then matches on words alone.
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder) *Facade {
// User Module
userRepo := repositories.NewUserRepository(db)
@@ -109,6 +112,13 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo)
catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService)
// Scan Module — a label from the customer's camera to "buy it here".
// Reads both databases: the catalogue to recognise the product, nearledb
// for who the customer is and what their outlets have on the shelf.
scanRepo := repositories.NewScanRepository(db, catalogueDB)
scanService := services.NewScanService(scanRepo, embedder)
scanController := controllers.NewScanController(scanService)
return &Facade{
UserController: userController,
ProductController: productController,
@@ -123,6 +133,7 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
PosController: posController,
LiveController: liveController,
CatalogueUploadController: catalogueUploadController,
ScanController: scanController,
posService: posService,
}
}