Files
backend_fiesta/utils/geo.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

105 lines
3.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}