lookup endpoint updated

This commit is contained in:
2026-09-30 16:13:25 +05:30
parent d0804ae84f
commit ea90b95cdc
4 changed files with 193 additions and 11 deletions

View File

@@ -56,13 +56,47 @@ type ScanStore struct {
// store: the matched product itself, or one of its sizes. Each is a real // store: the matched product itself, or one of its sizes. Each is a real
// product row with its own price and stock, which is why they are flat. // product row with its own price and stock, which is why they are flat.
type ScanOption struct { type ScanOption struct {
Productid int `json:"productid"` // Where this is sold.
Productname string `json:"productname"` //
Size string `json:"size"` // "500 g", "1 kg" — unitvalue + productunit // On the option and not only on the enclosing store, because an order line
Price float64 `json:"price"` // carries both and the app would otherwise have to reach back up the
Stock int `json:"stock"` // response to build one. `Locationid` is the real outlet and never 0.
Available bool `json:"available"` Tenantid int `json:"tenantid"`
Image string `json:"image,omitempty"` Locationid int `json:"locationid"`
Productid int `json:"productid"`
Productname string `json:"productname"`
// The pack size, both ways round.
//
// `Size` is the printable "500 g" the app has always shown. The two parts
// are sent beside it because an order line needs the unit on its own, and
// pulling it back out of the label means parsing a string a shop typed.
Size string `json:"size"`
Unitvalue string `json:"unitvalue"`
Productunit string `json:"productunit"`
// What the customer pays: the outlet's own price, or the product's retail
// price where the outlet has not set one.
Price float64 `json:"price"`
// What the shop paid. NOT a price to charge — it is `products.productcost`,
// the same field the product screens return, and billing against it would
// sell at cost.
Productcost float64 `json:"productcost"`
// Carried on the order header, so the app has them without a second read.
Categoryid int `json:"categoryid"`
Subcategoryid int `json:"subcategoryid"`
Stock int `json:"stock"`
Available bool `json:"available"`
// The same URL under both names: `image` is what this endpoint has always
// sent, `productimage` is what every other product response calls it and
// what an order line is built from.
Image string `json:"image,omitempty"`
Productimage string `json:"productimage,omitempty"`
// Is this the product that matched, or a size hanging under it? // Is this the product that matched, or a size hanging under it?
IsVariant bool `json:"is_variant"` IsVariant bool `json:"is_variant"`
Variantname string `json:"variantname,omitempty"` Variantname string `json:"variantname,omitempty"`

View File

@@ -72,7 +72,12 @@ type StoreOptionRow struct {
Productunit string Productunit string
Unitvalue string Unitvalue string
Price float64 Price float64
Stock int // The shop's own cost, distinct from Price. Selected because the product
// screens already return it and the app asked for it by name.
Productcost float64
Categoryid int
Subcategoryid int
Stock int
// For a size row: the product it hangs under and the label given to it. // For a size row: the product it hangs under and the label given to it.
Parentid int Parentid int
Variantname string Variantname string
@@ -246,6 +251,9 @@ const storeOptionSelect = `
COALESCE(a.productunit, '') AS productunit, COALESCE(a.productunit, '') AS productunit,
COALESCE(a.unitvalue, '') AS unitvalue, COALESCE(a.unitvalue, '') AS unitvalue,
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price, CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
COALESCE(a.productcost, 0) AS productcost,
COALESCE(a.categoryid, 0) AS categoryid,
COALESCE(a.subcategoryid, 0) AS subcategoryid,
COALESCE(( COALESCE((
SELECT SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity SELECT SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity
WHEN LOWER(c.stocktype) = 'out' THEN -c.quantity WHEN LOWER(c.stocktype) = 'out' THEN -c.quantity

119
services/scanOption_test.go Normal file
View File

@@ -0,0 +1,119 @@
package services
import (
"encoding/json"
"testing"
"nearle/repositories"
)
/*
What the app needs to turn a scan into an order.
The scan screen is the start of a checkout: a customer photographs a packet and
the next thing they do is add it to a basket. Every field an order line carries
has to be on the option itself — reaching back up the response for the store, or
splitting "500 g" to recover the unit, is the app doing work the server already
did.
*/
func TestAScannedOptionCarriesEverythingAnOrderLineNeeds(t *testing.T) {
row := repositories.StoreOptionRow{
Tenantid: 1147, Locationid: 1170, Productid: 7101,
Productname: "Balaji Wafers Simply Salted 135g",
Productimage: "https://example.test/wafers.jpg",
Unitvalue: "135", Productunit: "g",
Price: 35, Productcost: 29,
Categoryid: 2, Subcategoryid: 21,
Stock: 4,
}
opt := optionFromRow(row, nil)
if opt.Tenantid != 1147 || opt.Locationid != 1170 {
t.Errorf("store keys missing: tenant=%d location=%d", opt.Tenantid, opt.Locationid)
}
if opt.Locationid == 0 {
t.Error("locationid is 0 — the app cannot place an order against no outlet")
}
if opt.Unitvalue != "135" || opt.Productunit != "g" {
t.Errorf("unit parts missing: %q %q", opt.Unitvalue, opt.Productunit)
}
if opt.Size != "135 g" {
// The printable label stays: the app has always shown it.
t.Errorf("size label changed: %q", opt.Size)
}
if opt.Categoryid != 2 || opt.Subcategoryid != 21 {
t.Errorf("category ids missing: %d %d", opt.Categoryid, opt.Subcategoryid)
}
if opt.Productimage != "https://example.test/wafers.jpg" || opt.Image != opt.Productimage {
t.Errorf("image not under both names: %q %q", opt.Image, opt.Productimage)
}
}
func TestPriceAndCostAreNotTheSameField(t *testing.T) {
// The money one. `price` is what the customer pays; `productcost` is what
// the shop paid. Billing against the cost would sell at cost, so they must
// never be conflated.
opt := optionFromRow(repositories.StoreOptionRow{
Productid: 1, Price: 35, Productcost: 29,
}, nil)
if opt.Price != 35 {
t.Errorf("price = %v, want 35", opt.Price)
}
if opt.Productcost != 29 {
t.Errorf("productcost = %v, want 29", opt.Productcost)
}
if opt.Price == opt.Productcost {
t.Error("price and cost collapsed into one number")
}
}
func TestTheWireNamesTheAppAskedFor(t *testing.T) {
opt := optionFromRow(repositories.StoreOptionRow{
Tenantid: 1147, Locationid: 1170, Productid: 7101,
Productname: "x", Productimage: "y",
Unitvalue: "135", Productunit: "g",
Price: 35, Productcost: 29, Categoryid: 2, Subcategoryid: 21,
}, nil)
encoded, err := json.Marshal(opt)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]any
if err := json.Unmarshal(encoded, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, key := range []string{
"productid", "tenantid", "locationid", "productcost", "price",
"productname", "productimage", "unitvalue", "productunit",
"categoryid", "subcategoryid",
} {
if _, ok := out[key]; !ok {
t.Errorf("missing %q: %s", key, encoded)
}
}
}
func TestASizeRowKeepsItsOwnStoreAndPrice(t *testing.T) {
// A variant hanging under a matched product is a separate sellable row with
// its own price and its own outlet. It is what the customer actually taps
// when they pick 1 kg instead of 500 g.
opt := optionFromRow(repositories.StoreOptionRow{
Tenantid: 1147, Locationid: 1170, Productid: 7102,
Parentid: 7101, Variantname: "1 kg",
Unitvalue: "1", Productunit: "kg", Price: 240, Productcost: 200,
}, nil)
if !opt.IsVariant || opt.MatchedBy != "variant-of:7101" {
t.Fatalf("not recognised as a size: %+v", opt)
}
if opt.Tenantid != 1147 || opt.Locationid != 1170 || opt.Price != 240 {
t.Errorf("a size row lost its store or price: %+v", opt)
}
if opt.Unitvalue != "1" || opt.Productunit != "kg" {
t.Errorf("a size row lost its unit: %q %q", opt.Unitvalue, opt.Productunit)
}
}

View File

@@ -856,13 +856,34 @@ func buildOffers(stores []models.ScanStore, rows []repositories.StoreOptionRow,
func optionFromRow(r repositories.StoreOptionRow, keys []repositories.CatalogueKey) models.ScanOption { func optionFromRow(r repositories.StoreOptionRow, keys []repositories.CatalogueKey) models.ScanOption {
opt := models.ScanOption{ opt := models.ScanOption{
// Repeated from the enclosing store on purpose: an order line carries
// both, and the app should not have to reach back up the response.
Tenantid: r.Tenantid,
Locationid: r.Locationid,
Productid: r.Productid, Productid: r.Productid,
Productname: r.Productname, Productname: r.Productname,
// The printable label AND its two parts. The label is what the app has
// always shown; the parts are what an order line needs, and recovering
// them by splitting the label means parsing a string a shop typed.
Size: strings.TrimSpace(r.Unitvalue + " " + r.Productunit), Size: strings.TrimSpace(r.Unitvalue + " " + r.Productunit),
Unitvalue: r.Unitvalue,
Productunit: r.Productunit,
Price: r.Price, Price: r.Price,
Stock: r.Stock, Productcost: r.Productcost,
Available: r.Stock > 0,
Image: r.Productimage, Categoryid: r.Categoryid,
Subcategoryid: r.Subcategoryid,
Stock: r.Stock,
Available: r.Stock > 0,
// One URL, both names — see the struct.
Image: r.Productimage,
Productimage: r.Productimage,
IsVariant: r.Parentid > 0, IsVariant: r.Parentid > 0,
Variantname: r.Variantname, Variantname: r.Variantname,
MatchedBy: "name", MatchedBy: "name",