735 lines
23 KiB
Go
735 lines
23 KiB
Go
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. A correct label
|
|
// scores ~0.92 against its own product's vector and ~0.23 against an
|
|
// unrelated one, so the floor sits in the empty middle of that split
|
|
// rather than just above the unrelated band: at 0.30, "Paracetamol"
|
|
// came back as "Paneer Makhni 500ml" (0.304) — a near-miss on an
|
|
// unrelated row clears a floor set that close to the noise.
|
|
scanMinScore = 0.50
|
|
)
|
|
|
|
// 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
|
|
|
|
// Distance on the store they tapped, for every outcome and not just the
|
|
// out-of-stock one below: the app renders this store from the reply it
|
|
// gets. The phone's fix is free to parse; the saved address costs a
|
|
// query, so it is only reached for on the path that also ranks other
|
|
// outlets. Without either, distanceKm leaves the -1 the repository set.
|
|
lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude))
|
|
chosen.DistanceKm = distanceKm(*chosen, lat, lng, hasPos)
|
|
|
|
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.
|
|
if !hasPos {
|
|
if hl, hg, ok, err := s.repo.CustomerHome(ctx, req.Customerid); err == nil && ok {
|
|
lat, lng, hasPos = hl, hg, true
|
|
chosen.DistanceKm = distanceKm(*chosen, lat, lng, hasPos)
|
|
}
|
|
}
|
|
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 }
|