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 }
|
||||
Reference in New Issue
Block a user