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:
141
models/scan.go
Normal file
141
models/scan.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package models
|
||||
|
||||
// Scan-to-order.
|
||||
//
|
||||
// A customer points the app at a packet, Google Lens (on the phone) reads a
|
||||
// label off it, and the app asks: "which of MY shops has this, in what sizes,
|
||||
// and which one should I buy from?" These are the shapes on both sides of
|
||||
// that conversation.
|
||||
|
||||
// ScanLookupRequest is what the app sends once Lens has produced a label.
|
||||
type ScanLookupRequest struct {
|
||||
Customerid int `json:"customerid"`
|
||||
// What Lens read: "Milk Bikis", "Dabur Honey 500g". Free text, trimmed
|
||||
// and capped by the service.
|
||||
Label string `json:"label"`
|
||||
// Where the customer is right now. Optional: without it the customer's
|
||||
// saved primary address is used, and without that stores are listed in
|
||||
// registration order with no distance.
|
||||
Latitude FlexibleString `json:"latitude"`
|
||||
Longitude FlexibleString `json:"longitude"`
|
||||
// The tenants the app believes the customer has scanned into. Optional and
|
||||
// never trusted on its own: the server intersects it with the
|
||||
// tenantcustomers table and reports anything it dropped.
|
||||
Tenantids []int `json:"tenantids"`
|
||||
// How many stores to return. 0 = all registered stores that stock it.
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// ScanStore is one of the customer's registered outlets.
|
||||
type ScanStore struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Tenantname string `json:"tenantname"`
|
||||
Locationid int `json:"locationid"`
|
||||
Locationname string `json:"locationname"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
// Kilometres from the customer, or -1 when either side has no usable
|
||||
// coordinates. Never omitted: a missing number is easy to misread as 0.
|
||||
DistanceKm float64 `json:"distance_km"`
|
||||
// Delivery reach in the outlet's own units, straight from tenantlocations.
|
||||
Deliveryradius int `json:"deliveryradius"`
|
||||
Deliverymins int `json:"deliverymins"`
|
||||
Open bool `json:"open"`
|
||||
}
|
||||
|
||||
// ScanOption is one thing the customer can actually put in the basket at one
|
||||
// store: the matched product itself, or one of its sizes. Each is a real
|
||||
// product row with its own price and stock, which is why they are flat.
|
||||
type ScanOption struct {
|
||||
Productid int `json:"productid"`
|
||||
Productname string `json:"productname"`
|
||||
Size string `json:"size"` // "500 g", "1 kg" — unitvalue + productunit
|
||||
Price float64 `json:"price"`
|
||||
Stock int `json:"stock"`
|
||||
Available bool `json:"available"`
|
||||
Image string `json:"image,omitempty"`
|
||||
// Is this the product that matched, or a size hanging under it?
|
||||
IsVariant bool `json:"is_variant"`
|
||||
Variantname string `json:"variantname,omitempty"`
|
||||
// How the row was tied back to the catalogue: "imageid",
|
||||
// "brand+catalogueid", "name" or "variant-of:<productid>".
|
||||
MatchedBy string `json:"matched_by"`
|
||||
}
|
||||
|
||||
// ScanStoreOffer is one store and what it can sell.
|
||||
type ScanStoreOffer struct {
|
||||
ScanStore
|
||||
// Nearest store with at least one option in stock. Exactly one offer
|
||||
// carries this, and only when something is in stock somewhere.
|
||||
Recommended bool `json:"recommended"`
|
||||
// Any option in stock here.
|
||||
Available bool `json:"available"`
|
||||
Options []ScanOption `json:"options"`
|
||||
}
|
||||
|
||||
// ScanCatalogueMatch is what the catalogue search settled on.
|
||||
type ScanCatalogueMatch struct {
|
||||
Brand string `json:"brand"`
|
||||
Catalogueid int64 `json:"catalogueid"`
|
||||
Imageid string `json:"imageid,omitempty"`
|
||||
ProductName string `json:"product_name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
VariantKey string `json:"variant_key,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
// "vector", "vector+text" or "text" — how the score was produced. The app
|
||||
// can be more cautious with a text-only match.
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// ScanLookupResponse is the answer to a scan.
|
||||
type ScanLookupResponse struct {
|
||||
Label string `json:"label"`
|
||||
// The best catalogue product for the label, and the sizes of it the
|
||||
// catalogue knows about (each a separate catalogue row).
|
||||
Match *ScanCatalogueMatch `json:"match"`
|
||||
Variants []ScanCatalogueMatch `json:"catalogue_variants"`
|
||||
// 0..1. Below ~0.5 the app should confirm with the customer before
|
||||
// showing prices.
|
||||
Confidence float64 `json:"confidence"`
|
||||
// Registered stores that stock the product, nearest first, in-stock
|
||||
// first. Empty with Available=false when none does.
|
||||
Stores []ScanStoreOffer `json:"stores"`
|
||||
Available bool `json:"available"`
|
||||
// The locationid of the store marked Recommended, or 0.
|
||||
RecommendedLocationid int `json:"recommended_locationid"`
|
||||
// Tenant ids the app sent that the customer is not actually registered
|
||||
// with. Empty normally; non-empty means the app's local list is stale.
|
||||
UnregisteredTenantids []int `json:"unregistered_tenantids,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ScanConfirmRequest is sent when the customer taps a store and a size.
|
||||
type ScanConfirmRequest struct {
|
||||
Customerid int `json:"customerid"`
|
||||
Tenantid int `json:"tenantid"`
|
||||
Locationid int `json:"locationid"`
|
||||
Productid int `json:"productid"`
|
||||
Quantity int `json:"quantity"`
|
||||
Latitude FlexibleString `json:"latitude"`
|
||||
Longitude FlexibleString `json:"longitude"`
|
||||
}
|
||||
|
||||
// ScanConfirmResponse says whether the pick still holds, and where to go if
|
||||
// it does not.
|
||||
type ScanConfirmResponse struct {
|
||||
Ok bool `json:"ok"`
|
||||
// "in_stock", "insufficient_stock", "out_of_stock", "not_sold_here",
|
||||
// "store_not_registered".
|
||||
Reason string `json:"reason"`
|
||||
Store *ScanStore `json:"store,omitempty"`
|
||||
Option *ScanOption `json:"option,omitempty"`
|
||||
Requested int `json:"requested"`
|
||||
// The next-nearest registered store with enough of the same product, when
|
||||
// the chosen one has run out. Nil when there is none.
|
||||
Alternative *ScanStoreOffer `json:"alternative,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
Reference in New Issue
Block a user