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>
105 lines
3.1 KiB
Go
105 lines
3.1 KiB
Go
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
|
||
}
|