variants units

This commit is contained in:
2026-09-02 16:01:24 +05:30
parent 09efc5403f
commit e45da9f7df
8 changed files with 374 additions and 61 deletions

View File

@@ -24,8 +24,11 @@ type Customers struct {
Landmark string `json:"landmark"` Landmark string `json:"landmark"`
Doorno string `json:"doorno"` Doorno string `json:"doorno"`
Postcode string `json:"postcode"` Postcode string `json:"postcode"`
Latitude string `json:"latitude"` // Numbers from a map picker, strings from a text field — see
Longitude string `json:"longitude"` // 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"` Applocationid int `json:"applocationid"`
Locationid int `json:"locationid,omitempty" gorm:"-"` Locationid int `json:"locationid,omitempty" gorm:"-"`
Defaultaddress string `json:"defaultaddress,omitempty" gorm:"-"` Defaultaddress string `json:"defaultaddress,omitempty" gorm:"-"`
@@ -84,7 +87,9 @@ type CustomerLocationResult struct {
type Customerlocations struct { type Customerlocations struct {
Locationid int `json:"locationid" gorm:"Primary_Key"` Locationid int `json:"locationid" gorm:"Primary_Key"`
Customerid int `json:"customerid"` Customerid int `json:"customerid"`
Applocationid int `json:"applocationid" gorm:"-"` // 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"` Address string `json:"address"`
Suburb string `json:"suburb"` Suburb string `json:"suburb"`
City string `json:"city"` City string `json:"city"`
@@ -92,10 +97,25 @@ type Customerlocations struct {
Landmark string `json:"landmark"` Landmark string `json:"landmark"`
Doorno string `json:"doorno"` Doorno string `json:"doorno"`
Postcode string `json:"postcode"` Postcode string `json:"postcode"`
Latitude string `json:"latitude"` // Latitude and longitude arrive as NUMBERS from a map picker and as strings
Longitude string `json:"longitude"` // from a text field. A strict type rejected the first, and BodyParser fails
Primaryaddress int `json:"primaryaddress"` // the WHOLE request on one unreadable field — so a shopper who set their
Status int `json:"status"` // 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 { type CustomerRequest struct {

116
models/flexibleInt.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -65,7 +65,12 @@ type Productvariant struct {
Variantname string `json:"variantname"` Variantname string `json:"variantname"`
Varianttype string `json:"varianttype,omitempty"` Varianttype string `json:"varianttype,omitempty"`
Price float64 `json:"price,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"` Categoryid int `json:"categoryid" gorm:"default:0"`
Categoryname string `json:"categoryname" gorm:"-"` Categoryname string `json:"categoryname" gorm:"-"`
@@ -78,6 +83,12 @@ type Productvariant struct {
Variantproductname string `json:"variantproductname" gorm:"->"` Variantproductname string `json:"variantproductname" gorm:"->"`
Variantprice float64 `json:"variantprice" gorm:"->"` Variantprice float64 `json:"variantprice" gorm:"->"`
Variantstock int `json:"variantstock" 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 { type Products struct {
@@ -131,8 +142,8 @@ type Products struct {
Productsku string `json:"productsku,omitempty"` Productsku string `json:"productsku,omitempty"`
Brandid int `json:"brandid,omitempty"` Brandid int `json:"brandid,omitempty"`
Productbrand string `json:"productbrand,omitempty"` Productbrand string `json:"productbrand,omitempty"`
Productunit string `json:"productunit,omitempty"` Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue,omitempty"` Unitvalue string `json:"unitvalue"`
Toppicks string `json:"toppicks,omitempty"` Toppicks string `json:"toppicks,omitempty"`
Productcost float64 `json:"productcost,omitempty"` Productcost float64 `json:"productcost,omitempty"`
Taxamount float64 `json:"taxamount,omitempty"` Taxamount float64 `json:"taxamount,omitempty"`
@@ -163,7 +174,7 @@ type Products struct {
// returned only Retailprice, which the admin catalogue never writes. // returned only Retailprice, which the admin catalogue never writes.
// Same meaning as Locationproducts.Price, so both product feeds agree. // Same meaning as Locationproducts.Price, so both product feeds agree.
Price float64 `json:"price" gorm:"->"` Price float64 `json:"price" gorm:"->"`
Retailprice float64 `json:"retailprice,omitempty"` Retailprice float64 `json:"retailprice"`
Diffprice float64 `json:"diffprice,omitempty"` Diffprice float64 `json:"diffprice,omitempty"`
Diffpercent float64 `json:"diffpercent,omitempty"` Diffpercent float64 `json:"diffpercent,omitempty"`
Othercost float64 `json:"othercost,omitempty"` Othercost float64 `json:"othercost,omitempty"`
@@ -199,8 +210,8 @@ type Locationproducts struct {
Productsku string `json:"productsku,omitempty"` Productsku string `json:"productsku,omitempty"`
Brandid int `json:"brandid,omitempty"` Brandid int `json:"brandid,omitempty"`
Productbrand string `json:"productbrand,omitempty"` Productbrand string `json:"productbrand,omitempty"`
Productunit string `json:"productunit,omitempty"` Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue,omitempty"` Unitvalue string `json:"unitvalue"`
Toppicks string `json:"toppicks,omitempty"` Toppicks string `json:"toppicks,omitempty"`
Productcost float64 `json:"productcost,omitempty"` Productcost float64 `json:"productcost,omitempty"`
Taxamount float64 `json:"taxamount,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 // 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 and never read it back, so the UI always showed the master price.
Price float64 `json:"price" gorm:"->"` Price float64 `json:"price" gorm:"->"`
Retailprice float64 `json:"retailprice,omitempty"` Retailprice float64 `json:"retailprice"`
Diffprice float64 `json:"diffprice,omitempty"` Diffprice float64 `json:"diffprice,omitempty"`
Diffpercent float64 `json:"diffpercent,omitempty"` Diffpercent float64 `json:"diffpercent,omitempty"`
Othercost float64 `json:"othercost,omitempty"` Othercost float64 `json:"othercost,omitempty"`

View File

@@ -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))
}
}

View File

@@ -224,8 +224,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
case "picked": case "picked":
if data.Pickuplocationid != 0 && data.Deliverytype != "B" { if data.Pickuplocationid != 0 && data.Deliverytype != "B" {
cloc.Latitude = data.Riderslat cloc.Latitude = models.FlexibleString(data.Riderslat)
cloc.Longitude = data.Riderslon cloc.Longitude = models.FlexibleString(data.Riderslon)
cloc.Address = data.Address cloc.Address = data.Address
cloc.Suburb = data.Suburb cloc.Suburb = data.Suburb
cloc.City = data.City cloc.City = data.City
@@ -247,8 +247,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
} }
if data.Deliverylocationid != 0 && data.Deliverytype != "B" { if data.Deliverylocationid != 0 && data.Deliverytype != "B" {
cloc.Latitude = data.Deliverylat cloc.Latitude = models.FlexibleString(data.Deliverylat)
cloc.Longitude = data.Deliverylong cloc.Longitude = models.FlexibleString(data.Deliverylong)
cloc.Address = data.Address cloc.Address = data.Address
cloc.Suburb = data.Suburb cloc.Suburb = data.Suburb
cloc.City = data.City cloc.City = data.City

View File

@@ -123,7 +123,14 @@ func (r *productRepository) VariantsForProducts(tenantid, locationid int, produc
Select(` Select(`
v.*, v.*,
p.productname AS variantproductname, 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 SELECT pl.price FROM productlocations pl
WHERE pl.productid = v.variantproductid AND pl.tenantid = v.tenantid AND pl.locationid = ? WHERE pl.productid = v.variantproductid AND pl.tenantid = v.tenantid AND pl.locationid = ?
LIMIT 1 LIMIT 1

View File

@@ -695,8 +695,8 @@ func (r *tenantRepository) CreateTenantUser(data models.Tenants) (bool, error) {
cust.State = data.State cust.State = data.State
cust.Postcode = data.Postcode cust.Postcode = data.Postcode
cust.Applocationid = data.Applocationid cust.Applocationid = data.Applocationid
cust.Latitude = data.Latitude cust.Latitude = models.FlexibleString(data.Latitude)
cust.Longitude = data.Longitude cust.Longitude = models.FlexibleString(data.Longitude)
cust.Primaryaddress = 1 cust.Primaryaddress = 1
cid := r.CheckCustomer(data.Primarycontact) cid := r.CheckCustomer(data.Primarycontact)