Files
backend_fiesta/services/scanService.go
Suriyakumarvijayanayagam 72907dae74 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>
2026-09-15 17:04:34 +05:30

722 lines
22 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"log"
"math"
"nearle/models"
"nearle/repositories"
"nearle/utils"
"sort"
"strings"
"sync"
"time"
)
/*
Scan-to-order: from a label Lens read off a packet to "buy it here".
The pipeline, in the order it runs:
1. Who is asking, and where can they buy? The customer's registered outlets
(tenantcustomers → tenantlocations) and their position — the phone's
fix if it sent one, else the saved address.
2. What did they scan? The label goes to the catalogue: embedded and ranked
by pgvector when a model is configured, matched on words when it is not,
and both when it is (the text match settles near-ties). The vector and
the hits are cached; a second person scanning the same packet today does
not pay for the model call.
3. Which of THEIR outlets sell it, in which sizes, with how many on the
shelf right now? One read of nearledb keyed on the catalogue links every
imported product carries. Live stock is never cached.
4. Rank: in-stock outlets first, nearest first, and the first of those is
the recommendation. The customer may still tap any other.
Steps 1 and 2 touch different databases and run concurrently; the whole
lookup is bounded by scanLookupTimeout so a slow model degrades to a text
answer rather than a spinner.
What this deliberately does not do: reserve stock. The confirm call
re-reads the ledger and, if the shelf emptied in between, points at the next
outlet — the same answer a hold would give, without a hold to expire.
*/
const (
scanLookupTimeout = 5 * time.Second
scanMaxLabelLen = 200
scanCatalogueTopK = 15
// Below this the best hit is not shown as a match at all.
scanMinScore = 0.30
)
// ScanErrors the controller maps to statuses. Everything else is a 500.
var (
ErrScanBadRequest = errors.New("scan: bad request")
ErrScanCustomerNotFound = errors.New("scan: customer not found")
ErrScanCatalogueDown = errors.New("scan: catalogue unavailable")
)
type ScanService interface {
Lookup(ctx context.Context, req models.ScanLookupRequest) (*models.ScanLookupResponse, error)
Confirm(ctx context.Context, req models.ScanConfirmRequest) (*models.ScanConfirmResponse, error)
Stores(ctx context.Context, customerid int, lat, lng models.FlexibleString) ([]models.ScanStore, error)
}
type scanService struct {
repo repositories.ScanRepository
embedder utils.Embedder // nil → text matching only
}
func NewScanService(repo repositories.ScanRepository, embedder utils.Embedder) ScanService {
return &scanService{repo: repo, embedder: embedder}
}
// ── Lookup ──────────────────────────────────────────────────────────────────
func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest) (*models.ScanLookupResponse, error) {
label := strings.TrimSpace(req.Label)
if req.Customerid <= 0 {
return nil, fmt.Errorf("%w: customerid is required", ErrScanBadRequest)
}
if label == "" {
return nil, fmt.Errorf("%w: label is required", ErrScanBadRequest)
}
if len(label) > scanMaxLabelLen {
label = label[:scanMaxLabelLen]
}
ctx, cancel := context.WithTimeout(ctx, scanLookupTimeout)
defer cancel()
// Steps 1 and 2 in parallel — different databases, no dependency.
var (
wg sync.WaitGroup
stores []models.ScanStore
exists bool
homeLat float64
homeLng float64
homeOK bool
custErr error
hits []scoredHit
method string
matchErr error
)
wg.Add(2)
go func() {
defer wg.Done()
exists, custErr = s.repo.CustomerExists(ctx, req.Customerid)
if custErr != nil || !exists {
return
}
stores, custErr = s.repo.RegisteredStores(ctx, req.Customerid)
if custErr != nil {
return
}
// Only read the saved address when the phone sent nothing usable.
if _, _, ok := utils.ParseLatLng(string(req.Latitude), string(req.Longitude)); !ok {
homeLat, homeLng, homeOK, custErr = s.repo.CustomerHome(ctx, req.Customerid)
}
}()
go func() {
defer wg.Done()
hits, method, matchErr = s.searchCatalogue(ctx, label)
}()
wg.Wait()
if custErr != nil {
return nil, custErr
}
if !exists {
return nil, ErrScanCustomerNotFound
}
if matchErr != nil {
return nil, matchErr
}
lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude))
if !hasPos && homeOK {
lat, lng, hasPos = homeLat, homeLng, true
}
resp := &models.ScanLookupResponse{
Label: label,
Stores: []models.ScanStoreOffer{},
Variants: []models.ScanCatalogueMatch{},
}
// Verify the app's idea of the customer's tenants against the truth.
stores, resp.UnregisteredTenantids = restrictToTenants(stores, req.Tenantids)
if len(hits) == 0 || hits[0].score < scanMinScore {
resp.Message = "We couldn't recognise that product. Try a clearer photo of the front of the pack."
return resp, nil
}
best := hits[0]
family := catalogueFamily(hits)
resp.Match = ptr(best.toMatch(method))
resp.Confidence = round3(best.score)
for _, h := range family {
resp.Variants = append(resp.Variants, h.toMatch(method))
}
if len(stores) == 0 {
resp.Message = "You haven't joined a store yet. Scan a store's QR code in the app to shop from it."
return resp, nil
}
// Step 3: what those outlets have.
keys := make([]repositories.CatalogueKey, 0, len(family))
names := make([]string, 0, len(family))
for _, h := range family {
keys = append(keys, repositories.CatalogueKey{Brand: h.Brand, Catalogueid: h.ID, Imageid: h.ImageID})
names = append(names, h.ProductName)
}
locationids := make([]int, 0, len(stores))
for _, st := range stores {
locationids = append(locationids, st.Locationid)
}
rows, err := s.repo.StoreOptions(ctx, locationids, keys, names)
if err != nil {
return nil, err
}
// Step 4: rank.
offers := buildOffers(stores, rows, keys, lat, lng, hasPos)
if req.Limit > 0 && len(offers) > req.Limit {
offers = offers[:req.Limit]
}
resp.Stores = offers
for i := range offers {
if offers[i].Recommended {
resp.Available = true
resp.RecommendedLocationid = offers[i].Locationid
break
}
}
switch {
case resp.Available:
resp.Message = fmt.Sprintf("Available at %d of your stores.", countAvailable(offers))
case len(offers) > 0:
resp.Message = "Your stores sell this but it's out of stock right now."
default:
resp.Message = "None of your stores sell this product yet."
}
return resp, nil
}
// ── Confirm ─────────────────────────────────────────────────────────────────
func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest) (*models.ScanConfirmResponse, error) {
if req.Customerid <= 0 || req.Tenantid <= 0 || req.Locationid <= 0 || req.Productid <= 0 {
return nil, fmt.Errorf("%w: customerid, tenantid, locationid and productid are required", ErrScanBadRequest)
}
if req.Quantity <= 0 {
req.Quantity = 1
}
ctx, cancel := context.WithTimeout(ctx, scanLookupTimeout)
defer cancel()
stores, err := s.repo.RegisteredStores(ctx, req.Customerid)
if err != nil {
return nil, err
}
resp := &models.ScanConfirmResponse{Requested: req.Quantity}
var chosen *models.ScanStore
for i := range stores {
if stores[i].Tenantid == req.Tenantid && stores[i].Locationid == req.Locationid {
chosen = &stores[i]
break
}
}
if chosen == nil {
resp.Reason = "store_not_registered"
resp.Message = "You're not registered with that store. Scan its QR code first."
return resp, nil
}
resp.Store = chosen
row, err := s.repo.ProductAt(ctx, req.Tenantid, req.Locationid, req.Productid)
if err != nil {
return nil, err
}
if row == nil {
resp.Reason = "not_sold_here"
resp.Message = "That store doesn't sell this product."
return resp, nil
}
opt := optionFromRow(*row, nil)
resp.Option = &opt
if row.Stock >= req.Quantity {
resp.Ok = true
resp.Reason = "in_stock"
resp.Message = "In stock."
return resp, nil
}
if row.Stock > 0 {
resp.Reason = "insufficient_stock"
resp.Message = fmt.Sprintf("Only %d left at %s.", row.Stock, chosen.Locationname)
} else {
resp.Reason = "out_of_stock"
resp.Message = fmt.Sprintf("Out of stock at %s.", chosen.Locationname)
}
// The same product elsewhere, nearest first, with enough of it.
lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude))
if !hasPos {
if hl, hg, ok, err := s.repo.CustomerHome(ctx, req.Customerid); err == nil && ok {
lat, lng, hasPos = hl, hg, true
}
}
others := make([]models.ScanStore, 0, len(stores))
locationids := make([]int, 0, len(stores))
for _, st := range stores {
if st.Locationid == req.Locationid {
continue
}
others = append(others, st)
locationids = append(locationids, st.Locationid)
}
if len(others) == 0 {
return resp, nil
}
key := repositories.CatalogueKey{Brand: row.Productbrand, Catalogueid: row.Catalogueid, Imageid: row.Imageid}
rows, err := s.repo.StoreOptions(ctx, locationids, []repositories.CatalogueKey{key}, []string{row.Productname})
if err != nil {
return nil, err
}
// Only the product itself — not its other sizes: the customer chose a
// size and a different one is not a substitute they asked for.
var same []repositories.StoreOptionRow
for _, r := range rows {
if r.Stock >= req.Quantity && sameStoreProduct(*row, r) {
same = append(same, r)
}
}
offers := buildOffers(others, same, []repositories.CatalogueKey{key}, lat, lng, hasPos)
for i := range offers {
if offers[i].Available {
offers[i].Recommended = true
resp.Alternative = &offers[i]
resp.Message += fmt.Sprintf(" %s has it", offers[i].Locationname)
if offers[i].DistanceKm >= 0 {
resp.Message += fmt.Sprintf(" (%.1f km away)", offers[i].DistanceKm)
}
resp.Message += "."
break
}
}
return resp, nil
}
// ── Stores ──────────────────────────────────────────────────────────────────
func (s *scanService) Stores(ctx context.Context, customerid int, latStr, lngStr models.FlexibleString) ([]models.ScanStore, error) {
if customerid <= 0 {
return nil, fmt.Errorf("%w: customerid is required", ErrScanBadRequest)
}
ctx, cancel := context.WithTimeout(ctx, scanLookupTimeout)
defer cancel()
exists, err := s.repo.CustomerExists(ctx, customerid)
if err != nil {
return nil, err
}
if !exists {
return nil, ErrScanCustomerNotFound
}
stores, err := s.repo.RegisteredStores(ctx, customerid)
if err != nil {
return nil, err
}
lat, lng, hasPos := utils.ParseLatLng(string(latStr), string(lngStr))
if !hasPos {
if hl, hg, ok, err := s.repo.CustomerHome(ctx, customerid); err == nil && ok {
lat, lng, hasPos = hl, hg, true
}
}
for i := range stores {
stores[i].DistanceKm = distanceKm(stores[i], lat, lng, hasPos)
}
sort.SliceStable(stores, func(i, j int) bool { return nearer(stores[i].DistanceKm, stores[j].DistanceKm) })
if stores == nil {
stores = []models.ScanStore{}
}
return stores, nil
}
// ── Catalogue search ────────────────────────────────────────────────────────
type scoredHit struct {
repositories.CatalogueHit
score float64
}
func (h scoredHit) toMatch(method string) models.ScanCatalogueMatch {
return models.ScanCatalogueMatch{
Brand: h.Brand,
Catalogueid: h.ID,
Imageid: h.ImageID,
ProductName: h.ProductName,
Title: h.Title,
Category: h.Category,
Size: h.Size,
VariantKey: h.VariantKey,
Image: h.ImageURL,
Score: round3(h.score),
Method: method,
}
}
// searchCatalogue returns hits best-first and the method that produced them.
//
// Vector and text are combined as max(vector, text) with a small bonus when
// both agree. Max rather than a weighted sum so that a text-only hit — the
// exact product name typed on the pack — is never dragged below a vaguely
// similar vector neighbour, and a vector hit is never punished for a label
// Lens spelled slightly differently from the catalogue.
func (s *scanService) searchCatalogue(ctx context.Context, label string) ([]scoredHit, string, error) {
method := "text"
useVector := s.embedder != nil && s.repo.VectorSearchAvailable()
if useVector {
method = "vector+text"
}
if cached, ok := s.repo.CachedHits(ctx, method+":"+s.modelName(), label); ok {
return scoreCachedHits(cached), method, nil
}
tokens := utils.SearchTokens(label)
byKey := make(map[string]*scoredHit)
keyOf := func(h repositories.CatalogueHit) string { return h.Brand + "#" + fmt.Sprint(h.ID) }
if useVector {
vec, err := s.embed(ctx, label)
if err != nil {
// Degrade, loudly in the log and quietly to the customer: a text
// answer now beats a vector answer never.
log.Printf("scan: embedding %q failed, falling back to text: %v", label, err)
method = "text"
} else {
vhits, err := s.repo.VectorSearch(ctx, vec, scanCatalogueTopK)
if err != nil {
log.Printf("scan: vector search failed, falling back to text: %v", err)
method = "text"
}
for _, h := range vhits {
sim := 1 - h.Distance // cosine distance → similarity
if sim < 0 {
sim = 0
}
byKey[keyOf(h)] = &scoredHit{CatalogueHit: h, score: sim}
}
}
}
thits, err := s.repo.TextSearch(ctx, label, scanCatalogueTopK)
if err != nil {
if errors.Is(err, repositories.ErrCatalogueDBUnavailable) {
return nil, method, ErrScanCatalogueDown
}
if len(byKey) == 0 {
return nil, method, err
}
log.Printf("scan: text search failed, vector only: %v", err)
}
for _, h := range thits {
ts := textScore(h, label, tokens)
if existing, ok := byKey[keyOf(h)]; ok {
existing.score = math.Min(1, math.Max(existing.score, ts)+0.10)
continue
}
byKey[keyOf(h)] = &scoredHit{CatalogueHit: h, score: ts}
}
hits := make([]scoredHit, 0, len(byKey))
for _, h := range byKey {
hits = append(hits, *h)
}
sortHits(hits)
// Remember the ranked rows with their score folded into Distance, so the
// cache does not need a second shape.
toCache := make([]repositories.CatalogueHit, 0, len(hits))
for _, h := range hits {
c := h.CatalogueHit
c.Distance = 1 - h.score
toCache = append(toCache, c)
}
s.repo.CacheHits(ctx, method+":"+s.modelName(), label, toCache)
return hits, method, nil
}
func scoreCachedHits(cached []repositories.CatalogueHit) []scoredHit {
hits := make([]scoredHit, 0, len(cached))
for _, c := range cached {
hits = append(hits, scoredHit{CatalogueHit: c, score: 1 - c.Distance})
}
sortHits(hits)
return hits
}
func sortHits(hits []scoredHit) {
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
return hits[i].ProductName < hits[j].ProductName
})
}
func (s *scanService) modelName() string {
if s.embedder == nil {
return "none"
}
return s.embedder.Model()
}
func (s *scanService) embed(ctx context.Context, label string) ([]float32, error) {
if v, ok := s.repo.CachedVector(ctx, s.embedder.Model(), label); ok {
return v, nil
}
v, err := s.embedder.Embed(ctx, label)
if err != nil {
return nil, err
}
s.repo.CacheVector(ctx, s.embedder.Model(), label, v)
return v, nil
}
// textScore is how well a catalogue row's name matches the words Lens read.
// The whole label as a substring of the name is near-certain; otherwise the
// share of label words found in name+title, scaled so that "all of them"
// stops short of the substring case.
func textScore(h repositories.CatalogueHit, label string, tokens []string) float64 {
name := strings.ToLower(h.ProductName)
hay := name + " " + strings.ToLower(h.Title)
label = strings.ToLower(strings.TrimSpace(label))
if label != "" && strings.Contains(name, label) {
return 0.95
}
if len(tokens) == 0 {
return 0
}
found := 0
for _, t := range tokens {
if strings.Contains(hay, t) {
found++
}
}
return 0.8 * float64(found) / float64(len(tokens))
}
// catalogueFamily is the best hit and its other pack sizes: same brand, and
// the same variant_key when the catalogue assigned one, else the same name.
// Every member is a separate catalogue row a shop may have imported.
func catalogueFamily(hits []scoredHit) []scoredHit {
if len(hits) == 0 {
return nil
}
best := hits[0]
family := []scoredHit{best}
for _, h := range hits[1:] {
if h.Brand != best.Brand {
continue
}
switch {
case best.VariantKey != "" && h.VariantKey != "":
if h.VariantKey == best.VariantKey {
family = append(family, h)
}
case strings.EqualFold(strings.TrimSpace(h.ProductName), strings.TrimSpace(best.ProductName)):
family = append(family, h)
}
}
return family
}
// ── Offers ──────────────────────────────────────────────────────────────────
// buildOffers turns outlet rows into ranked offers: one per outlet that had
// any row, in-stock outlets first, nearest first, the first in-stock one
// recommended.
func buildOffers(stores []models.ScanStore, rows []repositories.StoreOptionRow, keys []repositories.CatalogueKey, lat, lng float64, hasPos bool) []models.ScanStoreOffer {
byLocation := make(map[int][]repositories.StoreOptionRow)
for _, r := range rows {
byLocation[r.Locationid] = append(byLocation[r.Locationid], r)
}
offers := make([]models.ScanStoreOffer, 0, len(byLocation))
for _, st := range stores {
rs := byLocation[st.Locationid]
if len(rs) == 0 {
continue
}
st.DistanceKm = distanceKm(st, lat, lng, hasPos)
offer := models.ScanStoreOffer{ScanStore: st, Options: []models.ScanOption{}}
// A product can arrive twice — as a direct match and as a size of
// another match. Keep the direct one; it carries the better label.
seen := make(map[int]int)
for _, r := range rs {
opt := optionFromRow(r, keys)
if idx, dup := seen[r.Productid]; dup {
if offer.Options[idx].IsVariant && !opt.IsVariant {
offer.Options[idx] = opt
}
continue
}
seen[r.Productid] = len(offer.Options)
offer.Options = append(offer.Options, opt)
if opt.Available {
offer.Available = true
}
}
// Direct matches first, then sizes; in stock before out.
sort.SliceStable(offer.Options, func(i, j int) bool {
a, b := offer.Options[i], offer.Options[j]
if a.Available != b.Available {
return a.Available
}
if a.IsVariant != b.IsVariant {
return !a.IsVariant
}
return a.Productname < b.Productname
})
offers = append(offers, offer)
}
sort.SliceStable(offers, func(i, j int) bool {
a, b := offers[i], offers[j]
if a.Available != b.Available {
return a.Available
}
if a.DistanceKm != b.DistanceKm {
return nearer(a.DistanceKm, b.DistanceKm)
}
return a.Locationname < b.Locationname
})
for i := range offers {
if offers[i].Available {
offers[i].Recommended = true
break
}
}
return offers
}
func optionFromRow(r repositories.StoreOptionRow, keys []repositories.CatalogueKey) models.ScanOption {
opt := models.ScanOption{
Productid: r.Productid,
Productname: r.Productname,
Size: strings.TrimSpace(r.Unitvalue + " " + r.Productunit),
Price: r.Price,
Stock: r.Stock,
Available: r.Stock > 0,
Image: r.Productimage,
IsVariant: r.Parentid > 0,
Variantname: r.Variantname,
MatchedBy: "name",
}
if r.Parentid > 0 {
opt.MatchedBy = fmt.Sprintf("variant-of:%d", r.Parentid)
return opt
}
for _, k := range keys {
if k.Imageid != "" && k.Imageid == r.Imageid {
opt.MatchedBy = "imageid"
return opt
}
if k.Brand != "" && strings.EqualFold(k.Brand, r.Productbrand) && k.Catalogueid == r.Catalogueid && k.Catalogueid > 0 {
opt.MatchedBy = "brand+catalogueid"
return opt
}
}
return opt
}
// sameStoreProduct is the cross-tenant identity of a product: the catalogue key
// when both rows carry one, the name otherwise.
func sameStoreProduct(a, b repositories.StoreOptionRow) bool {
if a.Imageid != "" && b.Imageid != "" {
return a.Imageid == b.Imageid
}
if a.Catalogueid > 0 && b.Catalogueid > 0 && a.Productbrand != "" {
return a.Catalogueid == b.Catalogueid && strings.EqualFold(a.Productbrand, b.Productbrand)
}
return strings.EqualFold(strings.TrimSpace(a.Productname), strings.TrimSpace(b.Productname))
}
// restrictToTenants keeps the outlets whose tenant the app named, and
// reports the names it got wrong. An app list that matches nothing is
// treated as stale rather than as "no stores": all registered outlets are
// used and every id it sent is reported.
func restrictToTenants(stores []models.ScanStore, tenantids []int) ([]models.ScanStore, []int) {
if len(tenantids) == 0 {
return stores, nil
}
registered := make(map[int]bool)
for _, st := range stores {
registered[st.Tenantid] = true
}
wanted := make(map[int]bool)
var unregistered []int
for _, id := range tenantids {
if registered[id] {
wanted[id] = true
} else {
unregistered = append(unregistered, id)
}
}
if len(wanted) == 0 {
return stores, unregistered
}
kept := make([]models.ScanStore, 0, len(stores))
for _, st := range stores {
if wanted[st.Tenantid] {
kept = append(kept, st)
}
}
return kept, unregistered
}
func distanceKm(st models.ScanStore, lat, lng float64, hasPos bool) float64 {
if !hasPos || (st.Latitude == 0 && st.Longitude == 0) {
return -1
}
return math.Round(utils.HaversineKm(lat, lng, st.Latitude, st.Longitude)*100) / 100
}
// nearer orders distances with -1 (unknown) last.
func nearer(a, b float64) bool {
if a < 0 {
return false
}
if b < 0 {
return true
}
return a < b
}
func countAvailable(offers []models.ScanStoreOffer) int {
n := 0
for _, o := range offers {
if o.Available {
n++
}
}
return n
}
func round3(f float64) float64 { return math.Round(f*1000) / 1000 }
func ptr[T any](v T) *T { return &v }