From e45da9f7df6eaa25a366e3f1a3a31159abc72465 Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 2 Sep 2026 16:01:24 +0530 Subject: [PATCH] variants units --- models/customer.go | 110 ++++++++++++++----------- models/flexibleInt.go | 116 +++++++++++++++++++++++++++ models/flexibleInt_test.go | 88 ++++++++++++++++++++ models/product.go | 29 ++++--- models/productWire_test.go | 71 ++++++++++++++++ repositories/deliveriesRepository.go | 8 +- repositories/productVariantLink.go | 9 ++- repositories/tenantRepository.go | 4 +- 8 files changed, 374 insertions(+), 61 deletions(-) create mode 100644 models/flexibleInt.go create mode 100644 models/flexibleInt_test.go create mode 100644 models/productWire_test.go diff --git a/models/customer.go b/models/customer.go index f9a631b..a18768c 100644 --- a/models/customer.go +++ b/models/customer.go @@ -3,37 +3,40 @@ package models import "time" type Customers struct { - Customerid int `json:"customerid" gorm:"Primary_Key"` - Firstname string `json:"firstname"` - Lastname string `json:"lastname"` - Profileimage string `json:"profileimage"` - Gender string `json:"gender"` - Dob string `json:"dob"` - Dialcode string `json:"dialcode"` - Contactno string `json:"contactno"` - Email string `json:"email"` - Deviceid string `json:"deviceid"` - Devicetype string `json:"devicetype"` - Authmode int `json:"authmode"` - Configid int `json:"configid"` - Customertoken string `json:"customertoken"` - Address string `json:"address"` - Suburb string `json:"suburb"` - City string `json:"city"` - State string `json:"state"` - Landmark string `json:"landmark"` - Doorno string `json:"doorno"` - Postcode string `json:"postcode"` - Latitude string `json:"latitude"` - Longitude string `json:"longitude"` - Applocationid int `json:"applocationid"` - Locationid int `json:"locationid,omitempty" gorm:"-"` - Defaultaddress string `json:"defaultaddress,omitempty" gorm:"-"` - Primaryaddress int `json:"primaryaddress,omitempty" gorm:"-"` - Tenantid int `json:"tenantid,omitempty" gorm:"-"` - Status int `json:"status"` - Intro string `json:"intro"` - Qrmode int `json:"qrmode,omitempty" gorm:"-"` + Customerid int `json:"customerid" gorm:"Primary_Key"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Profileimage string `json:"profileimage"` + Gender string `json:"gender"` + Dob string `json:"dob"` + Dialcode string `json:"dialcode"` + Contactno string `json:"contactno"` + Email string `json:"email"` + Deviceid string `json:"deviceid"` + Devicetype string `json:"devicetype"` + Authmode int `json:"authmode"` + Configid int `json:"configid"` + Customertoken string `json:"customertoken"` + Address string `json:"address"` + Suburb string `json:"suburb"` + City string `json:"city"` + State string `json:"state"` + Landmark string `json:"landmark"` + Doorno string `json:"doorno"` + Postcode string `json:"postcode"` + // Numbers from a map picker, strings from a text field — see + // Customerlocations below. A customer must exist before an order can be + // placed, so a 400 here blocks the whole flow, not just the address. + Latitude FlexibleString `json:"latitude"` + Longitude FlexibleString `json:"longitude"` + Applocationid int `json:"applocationid"` + Locationid int `json:"locationid,omitempty" gorm:"-"` + Defaultaddress string `json:"defaultaddress,omitempty" gorm:"-"` + Primaryaddress int `json:"primaryaddress,omitempty" gorm:"-"` + Tenantid int `json:"tenantid,omitempty" gorm:"-"` + Status int `json:"status"` + Intro string `json:"intro"` + Qrmode int `json:"qrmode,omitempty" gorm:"-"` } type CustomerInfo struct { @@ -82,20 +85,37 @@ type CustomerLocationResult struct { } type Customerlocations struct { - Locationid int `json:"locationid" gorm:"Primary_Key"` - Customerid int `json:"customerid"` - Applocationid int `json:"applocationid" gorm:"-"` - Address string `json:"address"` - Suburb string `json:"suburb"` - City string `json:"city"` - State string `json:"state"` - Landmark string `json:"landmark"` - Doorno string `json:"doorno"` - Postcode string `json:"postcode"` - Latitude string `json:"latitude"` - Longitude string `json:"longitude"` - Primaryaddress int `json:"primaryaddress"` - Status int `json:"status"` + Locationid int `json:"locationid" gorm:"Primary_Key"` + Customerid int `json:"customerid"` + // The query joins customers to select b.applocationid; `gorm:"-"` then threw + // it away, so every address came back with 0 however the join resolved. + Applocationid int `json:"applocationid" gorm:"->"` + Address string `json:"address"` + Suburb string `json:"suburb"` + City string `json:"city"` + State string `json:"state"` + Landmark string `json:"landmark"` + Doorno string `json:"doorno"` + Postcode string `json:"postcode"` + // Latitude and longitude arrive as NUMBERS from a map picker and as strings + // from a text field. A strict type rejected the first, and BodyParser fails + // the WHOLE request on one unreadable field — so a shopper who set their + // location on a map got 400 and no saved address. Same treatment the order + // model already gives Pickuplat and Pickuplong. + Latitude FlexibleString `json:"latitude"` + Longitude FlexibleString `json:"longitude"` + + // A "make this my default" checkbox sends true, a form sends 1, a text + // input sends "1". All three mean the same thing. + Primaryaddress FlexibleInt `json:"primaryaddress"` + + // Sent as 1, as "1", or as the word "Active", depending on the caller. + Status FlexibleInt `json:"status"` + + // Selected by GetCustomerLocations and, until now, dropped on the floor: + // the struct had no field for it, so the column marking the chosen address + // never reached the app. + Defaultaddress string `json:"defaultaddress"` } type CustomerRequest struct { diff --git a/models/flexibleInt.go b/models/flexibleInt.go new file mode 100644 index 0000000..3425718 --- /dev/null +++ b/models/flexibleInt.go @@ -0,0 +1,116 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// FlexibleInt is an integer column that accepts what clients actually send. +// +// The counterpart to FlexibleString, and it exists for the same reason: a strict +// type here does not reject one field, it rejects the WHOLE request. Fiber's +// BodyParser fails on the first field it cannot convert, so `"status": "Active"` +// or `"primaryaddress": true` returns 400 "Invalid request body" and nothing is +// saved. On the address form that surfaced as a shopper adding a delivery +// address, seeing no error worth acting on, and then finding no address to pick +// at checkout — measured 2026-09-02: three plausible payloads, three 400s. +// +// Accepted, in the shapes a form or a phone actually produces: +// +// 1 a number +// "1" a number as a string, which is what a text input gives +// true a checkbox — "set as my default address" +// "" an untouched field, read as 0 rather than an error +// "Active" a word, mapped by meaning: active/yes/true/default are 1 +// +// A word it does not recognise is an error, not a silent 0. Guessing there +// would store something the shopper did not choose. +type FlexibleInt int + +func (fi *FlexibleInt) UnmarshalJSON(b []byte) error { + if len(b) == 0 || string(b) == "null" { + return nil + } + + if b[0] == '"' { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + s = strings.TrimSpace(s) + if s == "" { + *fi = 0 + return nil + } + if n, err := strconv.Atoi(s); err == nil { + *fi = FlexibleInt(n) + return nil + } + switch strings.ToLower(s) { + case "active", "yes", "true", "default", "primary": + *fi = 1 + case "inactive", "no", "false": + *fi = 0 + default: + return fmt.Errorf("cannot read %q as a number", s) + } + return nil + } + + if b[0] == 't' || b[0] == 'f' { + var v bool + if err := json.Unmarshal(b, &v); err != nil { + return err + } + if v { + *fi = 1 + } else { + *fi = 0 + } + return nil + } + + // A number, possibly written with a decimal point by a client that has no + // integer type of its own. + var f float64 + if err := json.Unmarshal(b, &f); err != nil { + return err + } + *fi = FlexibleInt(int(f)) + return nil +} + +func (fi FlexibleInt) MarshalJSON() ([]byte, error) { + return json.Marshal(int(fi)) +} + +// Scan reads the column back. bigint arrives as int64; the string cases cover a +// text column that holds a number, which this schema has in places. +func (fi *FlexibleInt) Scan(value interface{}) error { + switch v := value.(type) { + case nil: + *fi = 0 + case int64: + *fi = FlexibleInt(v) + case int: + *fi = FlexibleInt(v) + case float64: + *fi = FlexibleInt(int(v)) + case []byte: + n, _ := strconv.Atoi(strings.TrimSpace(string(v))) + *fi = FlexibleInt(n) + case string: + n, _ := strconv.Atoi(strings.TrimSpace(v)) + *fi = FlexibleInt(n) + default: + return fmt.Errorf("cannot read %T as a number", value) + } + return nil +} + +func (fi FlexibleInt) Value() (driver.Value, error) { + return int64(fi), nil +} diff --git a/models/flexibleInt_test.go b/models/flexibleInt_test.go new file mode 100644 index 0000000..dbe83d5 --- /dev/null +++ b/models/flexibleInt_test.go @@ -0,0 +1,88 @@ +package models + +import ( + "encoding/json" + "testing" +) + +/* +A strict type on one field rejects the whole request. + +Fiber's BodyParser stops at the first field it cannot convert and returns 400 +"Invalid request body" — so `"primaryaddress": true` from a checkbox threw away +the address, the street and the pincode with it. The shopper saw no error worth +acting on and then had no address to choose at checkout. + +Measured 2026-09-02 against the running backend: three plausible payloads for +one address form, three 400s. +*/ + +func readInt(t *testing.T, body string) (FlexibleInt, error) { + t.Helper() + var v struct { + N FlexibleInt `json:"n"` + } + err := json.Unmarshal([]byte(`{"n":`+body+`}`), &v) + return v.N, err +} + +func mustRead(t *testing.T, body string, want FlexibleInt) { + t.Helper() + got, err := readInt(t, body) + if err != nil { + t.Fatalf("%s: %v", body, err) + } + if got != want { + t.Errorf("%s -> %d, want %d", body, got, want) + } +} + +func TestACheckboxIsANumber(t *testing.T) { + // "Set as my default address" sends a boolean. + mustRead(t, "true", 1) + mustRead(t, "false", 0) +} + +func TestATextInputIsANumber(t *testing.T) { + mustRead(t, `"1"`, 1) + mustRead(t, `"0"`, 0) +} + +func TestAWordIsReadByMeaning(t *testing.T) { + mustRead(t, `"Active"`, 1) + mustRead(t, `"active"`, 1) + mustRead(t, `"Inactive"`, 0) +} + +func TestAnUntouchedFieldIsZeroNotAnError(t *testing.T) { + mustRead(t, `""`, 0) + mustRead(t, "null", 0) +} + +func TestAClientWithNoIntegerTypeStillWorks(t *testing.T) { + // JavaScript has one number type, so 1 can arrive as 1.0. + mustRead(t, "1.0", 1) +} + +func TestAWordItCannotReadIsRefusedRatherThanGuessed(t *testing.T) { + // Storing 0 for something unrecognised would record a choice the shopper + // never made. Better to fail loudly than to invent an answer. + if _, err := readInt(t, `"maybe"`); err == nil { + t.Error(`"maybe" was silently accepted`) + } +} + +func TestLatitudeSurvivesBothShapes(t *testing.T) { + // A map picker sends a number; a text field sends a string. Both are the + // same place, and rejecting either loses the whole address. + var withNumber, withString Customerlocations + if err := json.Unmarshal([]byte(`{"latitude":11.0168}`), &withNumber); err != nil { + t.Fatalf("number latitude: %v", err) + } + if err := json.Unmarshal([]byte(`{"latitude":"11.0168"}`), &withString); err != nil { + t.Fatalf("string latitude: %v", err) + } + if withNumber.Latitude != withString.Latitude { + t.Errorf("%q from a number, %q from a string", withNumber.Latitude, withString.Latitude) + } +} diff --git a/models/product.go b/models/product.go index 49da448..3a2eee4 100644 --- a/models/product.go +++ b/models/product.go @@ -63,9 +63,14 @@ type Productvariant struct { // why a variant does not need to duplicate any of them. Variantproductid int `json:"variantproductid"` - Variantname string `json:"variantname"` - Varianttype string `json:"varianttype,omitempty"` - Price float64 `json:"price,omitempty"` + Variantname string `json:"variantname"` + Varianttype string `json:"varianttype,omitempty"` + + // A per-variant price override. 0 means "no override, use the product’s + // own price" — which is a real answer, so it is always emitted. With + // omitempty the key vanished at 0 and a client reading `price` got + // undefined, then rendered it. + Price float64 `json:"price"` Categoryid int `json:"categoryid" gorm:"default:0"` Categoryname string `json:"categoryname" gorm:"-"` @@ -78,6 +83,12 @@ type Productvariant struct { Variantproductname string `json:"variantproductname" gorm:"->"` Variantprice float64 `json:"variantprice" gorm:"->"` Variantstock int `json:"variantstock" gorm:"->"` + + // The size, as the product itself records it. Without these the only way to + // label a size was to join the parent’s unit fields to the variant’s name, + // which is how "null kg" reaches a screen. + Variantunitvalue string `json:"variantunitvalue" gorm:"->"` + Variantproductunit string `json:"variantproductunit" gorm:"->"` } type Products struct { @@ -131,8 +142,8 @@ type Products struct { Productsku string `json:"productsku,omitempty"` Brandid int `json:"brandid,omitempty"` Productbrand string `json:"productbrand,omitempty"` - Productunit string `json:"productunit,omitempty"` - Unitvalue string `json:"unitvalue,omitempty"` + Productunit string `json:"productunit"` + Unitvalue string `json:"unitvalue"` Toppicks string `json:"toppicks,omitempty"` Productcost float64 `json:"productcost,omitempty"` Taxamount float64 `json:"taxamount,omitempty"` @@ -163,7 +174,7 @@ type Products struct { // returned only Retailprice, which the admin catalogue never writes. // Same meaning as Locationproducts.Price, so both product feeds agree. Price float64 `json:"price" gorm:"->"` - Retailprice float64 `json:"retailprice,omitempty"` + Retailprice float64 `json:"retailprice"` Diffprice float64 `json:"diffprice,omitempty"` Diffpercent float64 `json:"diffpercent,omitempty"` Othercost float64 `json:"othercost,omitempty"` @@ -199,8 +210,8 @@ type Locationproducts struct { Productsku string `json:"productsku,omitempty"` Brandid int `json:"brandid,omitempty"` Productbrand string `json:"productbrand,omitempty"` - Productunit string `json:"productunit,omitempty"` - Unitvalue string `json:"unitvalue,omitempty"` + Productunit string `json:"productunit"` + Unitvalue string `json:"unitvalue"` Toppicks string `json:"toppicks,omitempty"` Productcost float64 `json:"productcost,omitempty"` Taxamount float64 `json:"taxamount,omitempty"` @@ -215,7 +226,7 @@ type Locationproducts struct { // productlocations row, not from products. Without it a store could set a // price and never read it back, so the UI always showed the master price. Price float64 `json:"price" gorm:"->"` - Retailprice float64 `json:"retailprice,omitempty"` + Retailprice float64 `json:"retailprice"` Diffprice float64 `json:"diffprice,omitempty"` Diffpercent float64 `json:"diffpercent,omitempty"` Othercost float64 `json:"othercost,omitempty"` diff --git a/models/productWire_test.go b/models/productWire_test.go new file mode 100644 index 0000000..3a81e0e --- /dev/null +++ b/models/productWire_test.go @@ -0,0 +1,71 @@ +package models + +import ( + "encoding/json" + "testing" +) + +/* +A missing string must reach a client as "", never as an absent key. + +`unitvalue` and `productunit` were both `omitempty`, and on this platform +`unitvalue` is empty for every product — measured 2026-09-02 on R mart: 24 +products, 0 with both fields, 7 with a unit and no value. So the key vanished +from the JSON, a client read `undefined`, and joining it to the unit rendered +the literal text "null kg" on a shopper's screen. + +The same applies to money: a price of 0 is a real answer ("this has no price +set"), and a client that receives no key at all cannot tell that from a field +it forgot to request. +*/ + +func fieldsOf(t *testing.T, v any) map[string]any { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +func TestAnEmptyUnitIsSentAsEmptyNotOmitted(t *testing.T) { + got := fieldsOf(t, Products{Productid: 1}) + for _, key := range []string{"unitvalue", "productunit"} { + if _, present := got[key]; !present { + t.Errorf("%q was omitted; a client reads that as null and renders it", key) + } + } +} + +func TestAZeroPriceIsSentAsZeroNotOmitted(t *testing.T) { + got := fieldsOf(t, Products{Productid: 1}) + if _, present := got["retailprice"]; !present { + t.Error("retailprice was omitted; 0 is a real answer and must be visible") + } +} + +func TestAVariantAlwaysCarriesItsPriceAndUnit(t *testing.T) { + got := fieldsOf(t, Productvariant{Variantid: 1}) + for _, key := range []string{"price", "variantprice", "variantunitvalue", "variantproductunit"} { + if _, present := got[key]; !present { + t.Errorf("variant option omitted %q", key) + } + } +} + +func TestAProductWithNoVariantsSendsAnEmptyListNotNull(t *testing.T) { + // The case that lets an order proceed: [] means "order this one directly". + // A JSON null would force every client to null-check before counting. + got := fieldsOf(t, Products{Productid: 1, Variantoptions: []Productvariant{}}) + list, ok := got["variantoptions"].([]any) + if !ok { + t.Fatalf("variantoptions was %T, want a list", got["variantoptions"]) + } + if len(list) != 0 { + t.Errorf("expected an empty list, got %d", len(list)) + } +} diff --git a/repositories/deliveriesRepository.go b/repositories/deliveriesRepository.go index 4515bc7..3b6d94b 100644 --- a/repositories/deliveriesRepository.go +++ b/repositories/deliveriesRepository.go @@ -224,8 +224,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus) case "picked": if data.Pickuplocationid != 0 && data.Deliverytype != "B" { - cloc.Latitude = data.Riderslat - cloc.Longitude = data.Riderslon + cloc.Latitude = models.FlexibleString(data.Riderslat) + cloc.Longitude = models.FlexibleString(data.Riderslon) cloc.Address = data.Address cloc.Suburb = data.Suburb cloc.City = data.City @@ -247,8 +247,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus) } if data.Deliverylocationid != 0 && data.Deliverytype != "B" { - cloc.Latitude = data.Deliverylat - cloc.Longitude = data.Deliverylong + cloc.Latitude = models.FlexibleString(data.Deliverylat) + cloc.Longitude = models.FlexibleString(data.Deliverylong) cloc.Address = data.Address cloc.Suburb = data.Suburb cloc.City = data.City diff --git a/repositories/productVariantLink.go b/repositories/productVariantLink.go index 87c8d89..25b2d42 100644 --- a/repositories/productVariantLink.go +++ b/repositories/productVariantLink.go @@ -123,7 +123,14 @@ func (r *productRepository) VariantsForProducts(tenantid, locationid int, produc Select(` v.*, p.productname AS variantproductname, - COALESCE(NULLIF(( + COALESCE(p.unitvalue, '') AS variantunitvalue, + COALESCE(p.productunit, '') AS variantproductunit, + -- The variant’s own override wins, then the outlet’s price, then the + -- master price. The override was being ignored entirely: a size + -- priced differently from its product was sold at the product’s + -- price. NULLIF because 0 in that column means "no override", not + -- "free". + COALESCE(NULLIF(v.price, 0), NULLIF(( SELECT pl.price FROM productlocations pl WHERE pl.productid = v.variantproductid AND pl.tenantid = v.tenantid AND pl.locationid = ? LIMIT 1 diff --git a/repositories/tenantRepository.go b/repositories/tenantRepository.go index 56f6fea..b05365d 100644 --- a/repositories/tenantRepository.go +++ b/repositories/tenantRepository.go @@ -695,8 +695,8 @@ func (r *tenantRepository) CreateTenantUser(data models.Tenants) (bool, error) { cust.State = data.State cust.Postcode = data.Postcode cust.Applocationid = data.Applocationid - cust.Latitude = data.Latitude - cust.Longitude = data.Longitude + cust.Latitude = models.FlexibleString(data.Latitude) + cust.Longitude = models.FlexibleString(data.Longitude) cust.Primaryaddress = 1 cid := r.CheckCustomer(data.Primarycontact)