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

104
utils/geo.go Normal file
View File

@@ -0,0 +1,104 @@
package utils
import (
"math"
"strconv"
"strings"
"time"
)
// ParseLatLng reads the coordinate strings this schema stores — customers,
// customerlocations and tenantlocations all hold latitude/longitude as text
// — and says whether they name a real place.
//
// "0,0" is rejected along with blanks: nothing on this platform is in the
// Gulf of Guinea, and it is what an empty map picker saves.
func ParseLatLng(lat, lng string) (float64, float64, bool) {
la, err1 := strconv.ParseFloat(strings.TrimSpace(lat), 64)
lo, err2 := strconv.ParseFloat(strings.TrimSpace(lng), 64)
if err1 != nil || err2 != nil {
return 0, 0, false
}
if la < -90 || la > 90 || lo < -180 || lo > 180 || (la == 0 && lo == 0) {
return 0, 0, false
}
return la, lo, true
}
// HaversineKm is the great-circle distance between two points.
func HaversineKm(lat1, lng1, lat2, lng2 float64) float64 {
const earthRadiusKm = 6371.0
toRad := func(d float64) float64 { return d * math.Pi / 180 }
dLat := toRad(lat2 - lat1)
dLng := toRad(lng2 - lng1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2)
return 2 * earthRadiusKm * math.Asin(math.Sqrt(a))
}
// OpenNow reads tenantlocations.opentime/closetime ("09:00", "21:30",
// "9:00 AM") against the wall clock. Unparsable or blank hours are treated as
// open: a shop that never filled the field in should not vanish from the
// list, and the ordering flow re-checks at checkout anyway.
func OpenNow(open, closeAt string, now time.Time) bool {
o, ok1 := parseClock(open)
c, ok2 := parseClock(closeAt)
if !ok1 || !ok2 || o == c {
return true
}
cur := now.Hour()*60 + now.Minute()
if o < c {
return cur >= o && cur < c
}
// Past midnight: "20:00" – "02:00".
return cur >= o || cur < c
}
func parseClock(s string) (int, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
for _, layout := range []string{"15:04", "15:04:05", "3:04 PM", "3:04PM", "03:04 PM", "15.04"} {
if t, err := time.Parse(layout, strings.ToUpper(s)); err == nil {
return t.Hour()*60 + t.Minute(), true
}
}
return 0, false
}
// SearchTokens splits a label into the words worth matching on: lowercased,
// punctuation stripped, single characters and pack-size noise dropped. "Milk
// Bikis 100g" → ["milk", "bikis"]; the size is matched separately, if at all.
func SearchTokens(label string) []string {
var tokens []string
seen := make(map[string]bool)
for _, raw := range strings.FieldsFunc(strings.ToLower(label), func(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
}) {
if len(raw) < 2 || isPackSize(raw) || seen[raw] {
continue
}
seen[raw] = true
tokens = append(tokens, raw)
}
return tokens
}
// isPackSize is "100g", "1kg", "500ml", "2l", "250gm" — a number with a unit
// glued on, or a bare number.
func isPackSize(tok string) bool {
digits := 0
for digits < len(tok) && tok[digits] >= '0' && tok[digits] <= '9' {
digits++
}
if digits == 0 {
return false
}
switch tok[digits:] {
case "", "g", "gm", "gms", "kg", "ml", "l", "ltr", "pcs", "pc", "x", "n":
return true
}
return false
}