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

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

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

@@ -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"`

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":
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

View File

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

View File

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