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:
721
services/scanService.go
Normal file
721
services/scanService.go
Normal file
@@ -0,0 +1,721 @@
|
||||
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 }
|
||||
438
services/scan_test.go
Normal file
438
services/scan_test.go
Normal file
@@ -0,0 +1,438 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/repositories"
|
||||
)
|
||||
|
||||
/*
|
||||
The scan pipeline has three decisions worth defending: which catalogue rows
|
||||
count as "the product", which of the customer's outlets get shown and in what
|
||||
order, and what happens when the outlet they tapped has run out. Everything
|
||||
below drives those through a fake repository; the SQL itself is exercised
|
||||
against a real database in scratch/ when there is one.
|
||||
*/
|
||||
|
||||
// fakeScanRepo answers from fixtures and records what it was asked.
|
||||
type fakeScanRepo struct {
|
||||
exists bool
|
||||
homeLat float64
|
||||
homeLng float64
|
||||
homeOK bool
|
||||
stores []models.ScanStore
|
||||
text []repositories.CatalogueHit
|
||||
vector []repositories.CatalogueHit
|
||||
hasVec bool
|
||||
options []repositories.StoreOptionRow
|
||||
at map[int]*repositories.StoreOptionRow // productid → row
|
||||
|
||||
askedKeys []repositories.CatalogueKey
|
||||
askedNames []string
|
||||
askedLocs []int
|
||||
cachedHits map[string][]repositories.CatalogueHit
|
||||
}
|
||||
|
||||
func (f *fakeScanRepo) CustomerExists(context.Context, int) (bool, error) { return f.exists, nil }
|
||||
func (f *fakeScanRepo) CustomerHome(context.Context, int) (float64, float64, bool, error) {
|
||||
return f.homeLat, f.homeLng, f.homeOK, nil
|
||||
}
|
||||
func (f *fakeScanRepo) RegisteredStores(context.Context, int) ([]models.ScanStore, error) {
|
||||
out := make([]models.ScanStore, len(f.stores))
|
||||
copy(out, f.stores)
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeScanRepo) StoreOptions(_ context.Context, locs []int, keys []repositories.CatalogueKey, names []string) ([]repositories.StoreOptionRow, error) {
|
||||
f.askedLocs, f.askedKeys, f.askedNames = locs, keys, names
|
||||
allowed := make(map[int]bool)
|
||||
for _, l := range locs {
|
||||
allowed[l] = true
|
||||
}
|
||||
var out []repositories.StoreOptionRow
|
||||
for _, o := range f.options {
|
||||
if allowed[o.Locationid] {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeScanRepo) ProductAt(_ context.Context, _, _, productid int) (*repositories.StoreOptionRow, error) {
|
||||
return f.at[productid], nil
|
||||
}
|
||||
func (f *fakeScanRepo) VectorSearch(context.Context, []float32, int) ([]repositories.CatalogueHit, error) {
|
||||
return f.vector, nil
|
||||
}
|
||||
func (f *fakeScanRepo) TextSearch(context.Context, string, int) ([]repositories.CatalogueHit, error) {
|
||||
return f.text, nil
|
||||
}
|
||||
func (f *fakeScanRepo) VectorSearchAvailable() bool { return f.hasVec }
|
||||
func (f *fakeScanRepo) CachedVector(context.Context, string, string) ([]float32, bool) {
|
||||
return nil, false
|
||||
}
|
||||
func (f *fakeScanRepo) CacheVector(context.Context, string, string, []float32) {}
|
||||
func (f *fakeScanRepo) CachedHits(_ context.Context, method, label string) ([]repositories.CatalogueHit, bool) {
|
||||
h, ok := f.cachedHits[method+"|"+label]
|
||||
return h, ok
|
||||
}
|
||||
func (f *fakeScanRepo) CacheHits(_ context.Context, method, label string, hits []repositories.CatalogueHit) {
|
||||
if f.cachedHits == nil {
|
||||
f.cachedHits = make(map[string][]repositories.CatalogueHit)
|
||||
}
|
||||
f.cachedHits[method+"|"+label] = hits
|
||||
}
|
||||
|
||||
type fakeEmbedder struct {
|
||||
vec []float32
|
||||
err error
|
||||
}
|
||||
|
||||
func (e fakeEmbedder) Embed(context.Context, string) ([]float32, error) { return e.vec, e.err }
|
||||
func (e fakeEmbedder) Model() string { return "fake-model" }
|
||||
|
||||
// A customer in Peelamedu with three outlets: one 1 km away, one 4 km away,
|
||||
// one across town with no coordinates on file.
|
||||
func fixtureStores() []models.ScanStore {
|
||||
return []models.ScanStore{
|
||||
{Tenantid: 1, Tenantname: "Suriya Store", Locationid: 10, Locationname: "Peelamedu", Latitude: 11.030, Longitude: 77.030},
|
||||
{Tenantid: 2, Tenantname: "R Mart", Locationid: 20, Locationname: "Hopes", Latitude: 11.010, Longitude: 77.000},
|
||||
{Tenantid: 3, Tenantname: "Daily Needs", Locationid: 30, Locationname: "Gandhipuram"},
|
||||
}
|
||||
}
|
||||
|
||||
var milkBikis = repositories.CatalogueHit{Brand: "britannia", ID: 7, ProductName: "Milk Bikis", Size: "100 g", VariantKey: "milk_bikis", ImageID: "britannia_milk_bikis_100g", Distance: 0.05}
|
||||
var milkBikis200 = repositories.CatalogueHit{Brand: "britannia", ID: 8, ProductName: "Milk Bikis", Size: "200 g", VariantKey: "milk_bikis", ImageID: "britannia_milk_bikis_200g", Distance: 0.12}
|
||||
var goodDay = repositories.CatalogueHit{Brand: "britannia", ID: 9, ProductName: "Good Day Butter", VariantKey: "good_day", ImageID: "britannia_good_day", Distance: 0.40}
|
||||
|
||||
func newLookupFixture() *fakeScanRepo {
|
||||
return &fakeScanRepo{
|
||||
exists: true,
|
||||
stores: fixtureStores(),
|
||||
hasVec: true,
|
||||
vector: []repositories.CatalogueHit{milkBikis, milkBikis200, goodDay},
|
||||
text: []repositories.CatalogueHit{milkBikis},
|
||||
options: []repositories.StoreOptionRow{
|
||||
// Nearest outlet: sells it, but the shelf is empty.
|
||||
{Tenantid: 1, Locationid: 10, Productid: 100, Productname: "Milk Bikis 100g", Productbrand: "britannia", Catalogueid: 7, Imageid: "britannia_milk_bikis_100g", Unitvalue: "100", Productunit: "g", Price: 10, Stock: 0},
|
||||
// 4 km away: has both sizes.
|
||||
{Tenantid: 2, Locationid: 20, Productid: 200, Productname: "Milk Bikis 100g", Productbrand: "britannia", Catalogueid: 7, Imageid: "britannia_milk_bikis_100g", Unitvalue: "100", Productunit: "g", Price: 12, Stock: 6},
|
||||
{Tenantid: 2, Locationid: 20, Productid: 201, Productname: "Milk Bikis 200g", Productbrand: "britannia", Catalogueid: 8, Imageid: "britannia_milk_bikis_200g", Unitvalue: "200", Productunit: "g", Price: 22, Stock: 3},
|
||||
// Same product listed again as a size under the 100g row — must not
|
||||
// appear twice.
|
||||
{Tenantid: 2, Locationid: 20, Productid: 201, Productname: "Milk Bikis 200g", Productbrand: "britannia", Catalogueid: 8, Imageid: "britannia_milk_bikis_200g", Unitvalue: "200", Productunit: "g", Price: 22, Stock: 3, Parentid: 200, Variantname: "200 g"},
|
||||
// Unknown distance, in stock.
|
||||
{Tenantid: 3, Locationid: 30, Productid: 300, Productname: "Milk Bikis", Productbrand: "britannia", Catalogueid: 7, Imageid: "britannia_milk_bikis_100g", Price: 11, Stock: 2},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupRecommendsTheNearestOutletWithStock(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1, 0.2}})
|
||||
|
||||
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||
Customerid: 5, Label: "Milk Bikis", Latitude: "11.035", Longitude: "77.035",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Match == nil || resp.Match.ProductName != "Milk Bikis" || resp.Match.Catalogueid != 7 {
|
||||
t.Fatalf("expected Milk Bikis 100 g as the match, got %+v", resp.Match)
|
||||
}
|
||||
if resp.Match.Method != "vector+text" {
|
||||
t.Errorf("method = %q, want vector+text", resp.Match.Method)
|
||||
}
|
||||
if len(resp.Variants) != 2 {
|
||||
t.Errorf("the catalogue family should be the two Milk Bikis sizes, got %d: %+v", len(resp.Variants), resp.Variants)
|
||||
}
|
||||
if !resp.Available || resp.RecommendedLocationid != 20 {
|
||||
t.Fatalf("Hopes (4 km, in stock) should be recommended over Peelamedu (1 km, empty); got available=%v recommended=%d", resp.Available, resp.RecommendedLocationid)
|
||||
}
|
||||
|
||||
// Order: in stock first (Hopes, then Gandhipuram with no distance), then
|
||||
// the empty nearest outlet.
|
||||
var order []int
|
||||
for _, o := range resp.Stores {
|
||||
order = append(order, o.Locationid)
|
||||
}
|
||||
if len(order) != 3 || order[0] != 20 || order[1] != 30 || order[2] != 10 {
|
||||
t.Fatalf("store order = %v, want [20 30 10]", order)
|
||||
}
|
||||
if !resp.Stores[0].Recommended || resp.Stores[1].Recommended || resp.Stores[2].Recommended {
|
||||
t.Error("exactly the first in-stock offer should be recommended")
|
||||
}
|
||||
if resp.Stores[2].Available {
|
||||
t.Error("Peelamedu has no stock and must not be available")
|
||||
}
|
||||
if resp.Stores[1].DistanceKm != -1 {
|
||||
t.Errorf("an outlet with no coordinates reports distance -1, got %v", resp.Stores[1].DistanceKm)
|
||||
}
|
||||
if resp.Stores[0].DistanceKm <= 0 || resp.Stores[0].DistanceKm > 10 {
|
||||
t.Errorf("Hopes should be a few km away, got %v", resp.Stores[0].DistanceKm)
|
||||
}
|
||||
|
||||
hopes := resp.Stores[0]
|
||||
if len(hopes.Options) != 2 {
|
||||
t.Fatalf("Hopes should offer two sizes once, got %d: %+v", len(hopes.Options), hopes.Options)
|
||||
}
|
||||
if hopes.Options[0].Productid != 200 || hopes.Options[0].MatchedBy != "imageid" || hopes.Options[0].Size != "100 g" {
|
||||
t.Errorf("first option should be the direct 100 g match by imageid, got %+v", hopes.Options[0])
|
||||
}
|
||||
if hopes.Options[1].Productid != 201 || hopes.Options[1].IsVariant {
|
||||
t.Errorf("the 200 g row seen both directly and as a size keeps the direct form, got %+v", hopes.Options[1])
|
||||
}
|
||||
|
||||
// Every catalogue size was asked for at every registered outlet.
|
||||
if len(repo.askedKeys) != 2 || len(repo.askedLocs) != 3 {
|
||||
t.Errorf("asked keys=%d locs=%d, want 2 and 3", len(repo.askedKeys), len(repo.askedLocs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupFallsBackToTextWhenTheModelFails(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
svc := NewScanService(repo, fakeEmbedder{err: errors.New("429 rate limited")})
|
||||
|
||||
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Match == nil || resp.Match.Method != "text" || resp.Match.Catalogueid != 7 {
|
||||
t.Fatalf("expected a text-only match on Milk Bikis, got %+v", resp.Match)
|
||||
}
|
||||
if resp.Confidence < 0.9 {
|
||||
t.Errorf("the label is the whole product name; confidence should be high, got %v", resp.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupWithoutAnEmbedderIsTextOnly(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
repo.hasVec = false
|
||||
svc := NewScanService(repo, nil)
|
||||
|
||||
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "milk bikis"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Match == nil || resp.Match.Method != "text" {
|
||||
t.Fatalf("expected text method, got %+v", resp.Match)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupUsesTheCachedRankingOnASecondScan(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
|
||||
|
||||
if _, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Take the catalogue away: the second scan must be served from cache.
|
||||
repo.vector, repo.text = nil, nil
|
||||
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Match == nil || resp.Match.Catalogueid != 7 {
|
||||
t.Fatalf("second scan should hit the cache, got %+v", resp.Match)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupRefusesAWeakMatch(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
repo.vector = []repositories.CatalogueHit{{Brand: "x", ID: 1, ProductName: "Something Else", Distance: 0.9}}
|
||||
repo.text = nil
|
||||
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
|
||||
|
||||
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "zzz"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Match != nil || resp.Available || len(resp.Stores) != 0 {
|
||||
t.Fatalf("a 0.1 similarity is not a match; got %+v", resp)
|
||||
}
|
||||
if repo.askedLocs != nil {
|
||||
t.Error("no outlet should be queried without a match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupVerifiesTheAppsTenantList(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
|
||||
|
||||
// The app says tenant 2 and tenant 99; 99 is not registered.
|
||||
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||
Customerid: 5, Label: "Milk Bikis", Tenantids: []int{2, 99},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.UnregisteredTenantids) != 1 || resp.UnregisteredTenantids[0] != 99 {
|
||||
t.Errorf("99 should be reported as unregistered, got %v", resp.UnregisteredTenantids)
|
||||
}
|
||||
if len(resp.Stores) != 1 || resp.Stores[0].Tenantid != 2 {
|
||||
t.Errorf("only tenant 2's outlet should be offered, got %+v", resp.Stores)
|
||||
}
|
||||
|
||||
// A list that matches nothing is stale, not a request for nothing.
|
||||
resp, _ = svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||
Customerid: 5, Label: "Milk Bikis", Tenantids: []int{98, 99},
|
||||
})
|
||||
if len(resp.Stores) != 3 || len(resp.UnregisteredTenantids) != 2 {
|
||||
t.Errorf("a wholly stale list falls back to every registered outlet, got %d stores / %v", len(resp.Stores), resp.UnregisteredTenantids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupWithNoStoresStillReturnsTheMatch(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
repo.stores = nil
|
||||
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
|
||||
|
||||
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Match == nil || resp.Available || len(resp.Stores) != 0 {
|
||||
t.Fatalf("match without stores, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupRejectsBadInput(t *testing.T) {
|
||||
svc := NewScanService(newLookupFixture(), nil)
|
||||
if _, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Label: "x"}); !errors.Is(err, ErrScanBadRequest) {
|
||||
t.Errorf("missing customerid: %v", err)
|
||||
}
|
||||
if _, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 1, Label: " "}); !errors.Is(err, ErrScanBadRequest) {
|
||||
t.Errorf("blank label: %v", err)
|
||||
}
|
||||
repo := newLookupFixture()
|
||||
repo.exists = false
|
||||
if _, err := NewScanService(repo, nil).Lookup(context.Background(), models.ScanLookupRequest{Customerid: 1, Label: "x"}); !errors.Is(err, ErrScanCustomerNotFound) {
|
||||
t.Errorf("unknown customer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmHoldsWhenStockIsThere(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
repo.at = map[int]*repositories.StoreOptionRow{200: &repo.options[1]}
|
||||
svc := NewScanService(repo, nil)
|
||||
|
||||
resp, err := svc.Confirm(context.Background(), models.ScanConfirmRequest{
|
||||
Customerid: 5, Tenantid: 2, Locationid: 20, Productid: 200, Quantity: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resp.Ok || resp.Reason != "in_stock" || resp.Option == nil || resp.Option.Stock != 6 {
|
||||
t.Fatalf("4 of 6 should be fine, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmPointsAtTheNextOutletWhenTheShelfIsEmpty(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
repo.at = map[int]*repositories.StoreOptionRow{100: &repo.options[0]} // Peelamedu, stock 0
|
||||
svc := NewScanService(repo, nil)
|
||||
|
||||
resp, err := svc.Confirm(context.Background(), models.ScanConfirmRequest{
|
||||
Customerid: 5, Tenantid: 1, Locationid: 10, Productid: 100, Quantity: 1,
|
||||
Latitude: "11.035", Longitude: "77.035",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Ok || resp.Reason != "out_of_stock" {
|
||||
t.Fatalf("expected out_of_stock, got %+v", resp)
|
||||
}
|
||||
if resp.Alternative == nil || resp.Alternative.Locationid != 20 {
|
||||
t.Fatalf("Hopes is the nearest outlet with the same 100 g product, got %+v", resp.Alternative)
|
||||
}
|
||||
if len(resp.Alternative.Options) != 1 || resp.Alternative.Options[0].Productid != 200 {
|
||||
t.Errorf("the alternative carries the same product, not its other sizes: %+v", resp.Alternative.Options)
|
||||
}
|
||||
if !resp.Alternative.Recommended {
|
||||
t.Error("the alternative is the recommendation")
|
||||
}
|
||||
|
||||
// Asking for more than anyone has: no alternative, honest reason.
|
||||
resp, _ = svc.Confirm(context.Background(), models.ScanConfirmRequest{
|
||||
Customerid: 5, Tenantid: 1, Locationid: 10, Productid: 100, Quantity: 50,
|
||||
})
|
||||
if resp.Alternative != nil {
|
||||
t.Errorf("nobody has 50; got alternative %+v", resp.Alternative)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmReportsInsufficientRatherThanOut(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
repo.at = map[int]*repositories.StoreOptionRow{300: &repo.options[4]} // Gandhipuram, stock 2
|
||||
svc := NewScanService(repo, nil)
|
||||
|
||||
resp, err := svc.Confirm(context.Background(), models.ScanConfirmRequest{
|
||||
Customerid: 5, Tenantid: 3, Locationid: 30, Productid: 300, Quantity: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Ok || resp.Reason != "insufficient_stock" {
|
||||
t.Fatalf("2 in stock, 5 asked: want insufficient_stock, got %+v", resp)
|
||||
}
|
||||
if resp.Alternative == nil || resp.Alternative.Locationid != 20 {
|
||||
t.Errorf("Hopes has 6 of the same product, got %+v", resp.Alternative)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmRefusesAnUnregisteredStoreAndAnUnsoldProduct(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
repo.at = map[int]*repositories.StoreOptionRow{}
|
||||
svc := NewScanService(repo, nil)
|
||||
|
||||
resp, _ := svc.Confirm(context.Background(), models.ScanConfirmRequest{Customerid: 5, Tenantid: 9, Locationid: 90, Productid: 1})
|
||||
if resp.Ok || resp.Reason != "store_not_registered" {
|
||||
t.Errorf("got %+v", resp)
|
||||
}
|
||||
resp, _ = svc.Confirm(context.Background(), models.ScanConfirmRequest{Customerid: 5, Tenantid: 1, Locationid: 10, Productid: 424242})
|
||||
if resp.Ok || resp.Reason != "not_sold_here" {
|
||||
t.Errorf("got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoresAreNearestFirstWithUnknownLast(t *testing.T) {
|
||||
repo := newLookupFixture()
|
||||
svc := NewScanService(repo, nil)
|
||||
|
||||
stores, err := svc.Stores(context.Background(), 5, "11.035", "77.035")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stores) != 3 || stores[0].Locationid != 10 || stores[1].Locationid != 20 || stores[2].Locationid != 30 {
|
||||
t.Fatalf("want [10 20 30], got %+v", stores)
|
||||
}
|
||||
|
||||
// No fix from the phone, saved address used instead.
|
||||
repo.homeLat, repo.homeLng, repo.homeOK = 11.012, 77.001, true
|
||||
stores, _ = svc.Stores(context.Background(), 5, "", "")
|
||||
if stores[0].Locationid != 20 {
|
||||
t.Errorf("from the saved address Hopes is nearest, got %+v", stores[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogueFamilyGroupsByVariantKeyThenName(t *testing.T) {
|
||||
hits := []scoredHit{
|
||||
{CatalogueHit: milkBikis, score: 0.95},
|
||||
{CatalogueHit: goodDay, score: 0.6},
|
||||
{CatalogueHit: milkBikis200, score: 0.88},
|
||||
{CatalogueHit: repositories.CatalogueHit{Brand: "parle", ProductName: "Milk Bikis", VariantKey: "milk_bikis"}, score: 0.5},
|
||||
}
|
||||
family := catalogueFamily(hits)
|
||||
if len(family) != 2 || family[1].ID != 8 {
|
||||
t.Fatalf("family should be the two britannia sizes, got %+v", family)
|
||||
}
|
||||
|
||||
// No variant keys: fall back to the name.
|
||||
a := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 1, ProductName: "Honey"}}
|
||||
b := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 2, ProductName: "honey "}}
|
||||
c := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 3, ProductName: "Honey Lite"}}
|
||||
if family := catalogueFamily([]scoredHit{a, b, c}); len(family) != 2 {
|
||||
t.Errorf("name match should join 1 and 2 only, got %+v", family)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user