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:
101
controllers/scanController.go
Normal file
101
controllers/scanController.go
Normal file
@@ -0,0 +1,101 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user