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>
142 lines
5.9 KiB
Go
142 lines
5.9 KiB
Go
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"`
|
|
}
|