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>
102 lines
3.0 KiB
Go
102 lines
3.0 KiB
Go
package controllers
|
|
|
|
import (
|
|
"errors"
|
|
"nearle/models"
|
|
"nearle/services"
|
|
"net/http"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// ScanController is the scan-to-order surface for the customer app:
|
|
//
|
|
// POST /v1/mob/scan/lookup a label → the product, and which of my stores has it
|
|
// POST /v1/mob/scan/confirm I picked a store and a size → still there? else where?
|
|
// GET /v1/mob/scan/stores my stores, nearest first
|
|
//
|
|
// Business outcomes ("out of stock", "not registered with that store") are
|
|
// 200s with a reason in the body: the app renders them, it does not retry
|
|
// them. HTTP errors are reserved for a request that cannot be served at all.
|
|
type ScanController struct {
|
|
scanService services.ScanService
|
|
}
|
|
|
|
func NewScanController(scanService services.ScanService) *ScanController {
|
|
return &ScanController{scanService: scanService}
|
|
}
|
|
|
|
func (ctl *ScanController) Lookup(c *fiber.Ctx) error {
|
|
var req models.ScanLookupRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return scanBadRequest(c, "Invalid request body")
|
|
}
|
|
resp, err := ctl.scanService.Lookup(c.Context(), req)
|
|
if err != nil {
|
|
return scanError(c, err, "Could not look up that product")
|
|
}
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"status": true,
|
|
"message": resp.Message,
|
|
"details": resp,
|
|
})
|
|
}
|
|
|
|
func (ctl *ScanController) Confirm(c *fiber.Ctx) error {
|
|
var req models.ScanConfirmRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return scanBadRequest(c, "Invalid request body")
|
|
}
|
|
resp, err := ctl.scanService.Confirm(c.Context(), req)
|
|
if err != nil {
|
|
return scanError(c, err, "Could not check that store")
|
|
}
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"status": true,
|
|
"message": resp.Message,
|
|
"details": resp,
|
|
})
|
|
}
|
|
|
|
func (ctl *ScanController) Stores(c *fiber.Ctx) error {
|
|
customerid, _ := c.QueryInt("customerid"), 0
|
|
stores, err := ctl.scanService.Stores(c.Context(), customerid,
|
|
models.FlexibleString(c.Query("latitude")), models.FlexibleString(c.Query("longitude")))
|
|
if err != nil {
|
|
return scanError(c, err, "Could not list your stores")
|
|
}
|
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"status": true,
|
|
"message": "Success",
|
|
"details": stores,
|
|
})
|
|
}
|
|
|
|
func scanBadRequest(c *fiber.Ctx, msg string) error {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"status": false,
|
|
"message": msg,
|
|
})
|
|
}
|
|
|
|
func scanError(c *fiber.Ctx, err error, fallback string) error {
|
|
code, msg := http.StatusInternalServerError, fallback
|
|
switch {
|
|
case errors.Is(err, services.ErrScanBadRequest):
|
|
code, msg = http.StatusBadRequest, err.Error()
|
|
case errors.Is(err, services.ErrScanCustomerNotFound):
|
|
code, msg = http.StatusNotFound, "Customer not found"
|
|
case errors.Is(err, services.ErrScanCatalogueDown):
|
|
code, msg = http.StatusServiceUnavailable, "Product search is temporarily unavailable"
|
|
}
|
|
return c.Status(code).JSON(fiber.Map{
|
|
"code": code,
|
|
"status": false,
|
|
"message": msg,
|
|
})
|
|
}
|