lookup endpoint updated
This commit is contained in:
@@ -56,13 +56,47 @@ type ScanStore struct {
|
||||
// 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.
|
||||
type ScanOption struct {
|
||||
Productid int `json:"productid"`
|
||||
Productname string `json:"productname"`
|
||||
Size string `json:"size"` // "500 g", "1 kg" — unitvalue + productunit
|
||||
Price float64 `json:"price"`
|
||||
Stock int `json:"stock"`
|
||||
Available bool `json:"available"`
|
||||
Image string `json:"image,omitempty"`
|
||||
// Where this is sold.
|
||||
//
|
||||
// On the option and not only on the enclosing store, because an order line
|
||||
// carries both and the app would otherwise have to reach back up the
|
||||
// response to build one. `Locationid` is the real outlet and never 0.
|
||||
Tenantid int `json:"tenantid"`
|
||||
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?
|
||||
IsVariant bool `json:"is_variant"`
|
||||
Variantname string `json:"variantname,omitempty"`
|
||||
|
||||
@@ -72,7 +72,12 @@ type StoreOptionRow struct {
|
||||
Productunit string
|
||||
Unitvalue string
|
||||
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.
|
||||
Parentid int
|
||||
Variantname string
|
||||
@@ -246,6 +251,9 @@ const storeOptionSelect = `
|
||||
COALESCE(a.productunit, '') AS productunit,
|
||||
COALESCE(a.unitvalue, '') AS unitvalue,
|
||||
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((
|
||||
SELECT SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity
|
||||
WHEN LOWER(c.stocktype) = 'out' THEN -c.quantity
|
||||
|
||||
119
services/scanOption_test.go
Normal file
119
services/scanOption_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -856,13 +856,34 @@ func buildOffers(stores []models.ScanStore, rows []repositories.StoreOptionRow,
|
||||
|
||||
func optionFromRow(r repositories.StoreOptionRow, keys []repositories.CatalogueKey) 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,
|
||||
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),
|
||||
Unitvalue: r.Unitvalue,
|
||||
Productunit: r.Productunit,
|
||||
|
||||
Price: r.Price,
|
||||
Stock: r.Stock,
|
||||
Available: r.Stock > 0,
|
||||
Image: r.Productimage,
|
||||
Productcost: r.Productcost,
|
||||
|
||||
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,
|
||||
Variantname: r.Variantname,
|
||||
MatchedBy: "name",
|
||||
|
||||
Reference in New Issue
Block a user