From ea90b95cdce2b52468f7f7bbbeec1bf7434ee778 Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 30 Sep 2026 16:13:25 +0530 Subject: [PATCH] lookup endpoint updated --- models/scan.go | 48 +++++++++++-- repositories/scanRepository.go | 10 ++- services/scanOption_test.go | 119 +++++++++++++++++++++++++++++++++ services/scanService.go | 27 +++++++- 4 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 services/scanOption_test.go diff --git a/models/scan.go b/models/scan.go index 1b54bbf..53d3609 100644 --- a/models/scan.go +++ b/models/scan.go @@ -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"` diff --git a/repositories/scanRepository.go b/repositories/scanRepository.go index c326071..3bbd83e 100644 --- a/repositories/scanRepository.go +++ b/repositories/scanRepository.go @@ -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 diff --git a/services/scanOption_test.go b/services/scanOption_test.go new file mode 100644 index 0000000..63962c6 --- /dev/null +++ b/services/scanOption_test.go @@ -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) + } +} diff --git a/services/scanService.go b/services/scanService.go index 7566e15..51466d0 100644 --- a/services/scanService.go +++ b/services/scanService.go @@ -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",