nutrition field

This commit is contained in:
2026-09-29 17:27:48 +05:30
parent b46902f51b
commit f9fb405974
9 changed files with 764 additions and 34 deletions

View File

@@ -26,9 +26,12 @@ type CatalogueProduct struct {
FSSAILicense string `json:"fssai_license,omitempty"`
Highlights PGStringArray `json:"highlights,omitempty"`
Nutrients PGStringArray `json:"nutrients,omitempty"`
SearchQuery string `json:"search_query,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
// The structured panel, when the catalogue carries one. Nil falls back to
// parsing Nutrients above — see NutritionFromLines.
Nutrition *NutritionPanel `json:"nutrition,omitempty"`
SearchQuery string `json:"search_query,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// CatalogueBrand describes a brand available in the catalogue DB.

137
models/nutrition.go Normal file
View File

@@ -0,0 +1,137 @@
package models
import (
"regexp"
"strconv"
"strings"
)
/*
The nutrition panel, as the customer app renders it.
── Why this is a type and not a list of strings ────────────────────────────
The catalogue already carries `nutrients`, a text[] of display lines like
"Energy 350kcal". That is enough to print bullets, which is what the console
does with it today, and not enough for an app: it cannot sort by a value, show
a per-serving column beside a per-100g one, or put the unit in a different
style from the number. It also has nowhere to say what the figures are PER,
which is the one piece of context that makes the rest meaningful — 520 kcal is
a fact about a quantity, and without "per 100g" it is a fact about nothing.
So this is the shape the agent team fills and the app reads. See
docs/NUTRITION_DATA.md for the contract.
── Why the field names are ugly ────────────────────────────────────────────
`servingsize`, not `serving_size` or `servingSize`. This is the shape the app
developer asked for, and an API is a promise to a client that has already been
written against it. Consistency with the rest of Fiesta — which is itself
inconsistent, `productid` beside `image_id` beside `sku_source` — is worth less
than not breaking the caller.
*/
type NutritionPanel struct {
// What the figures are measured against: "100g", "100ml", "1 serving".
Per string `json:"per,omitempty"`
// What the pack calls one serving: "30g". Separate from `Per` because a
// label routinely states both, and the app shows them in different places.
Servingsize string `json:"servingsize,omitempty"`
// Never nil when this panel exists — see `HasValues`. An app that receives
// `items: null` has to branch; one that receives `[]` does not, and a panel
// with no rows should not have been sent at all.
Items []NutritionItem `json:"items"`
}
// NutritionItem is one line of the panel.
type NutritionItem struct {
Name string `json:"name"`
// The figure. A float because saturated fat is 11.5g as often as it is 11g,
// and rounding it to please a type would be changing a label.
Value float64 `json:"value"`
// "kcal", "g", "mg". Free text on purpose: the label is the authority and a
// closed list here would mean refusing to carry whatever it actually says.
Unit string `json:"unit,omitempty"`
}
// HasValues reports whether this panel is worth sending.
//
// A panel with no rows is not a panel — it is an empty box on the product page,
// which reads as "this product has no nutrition" rather than "we do not know
// yet". The endpoint omits it instead.
func (p *NutritionPanel) HasValues() bool {
return p != nil && len(p.Items) > 0
}
/*
nutrientLine pulls "Energy 350kcal" apart.
── Why parsing exists at all ────────────────────────────────────────────────
Every catalogue row on the platform today holds nutrition as those display
strings and nothing else. Waiting for the agent team to refill all of them
before the app can show anything would mean shipping a field that is null for
every product, for as long as that takes.
So a structured panel is used when one exists, and one is derived from the
strings when it does not. The derived panel is strictly worse — it has no `per`
and no serving size, because the strings never carried them — and it is still
the difference between an app screen with values on it and an empty one.
── What it refuses to do ───────────────────────────────────────────────────
A line it cannot read is kept WHOLE as the name, with no value and no unit,
rather than dropped or guessed at. "Contains permitted natural colour" is a
real nutrition line and it has no number in it; binning it would quietly lose
label text, and forcing a 0 into it would state something false about the food.
*/
var nutrientLine = regexp.MustCompile(`^(.*?)[\s:]*(-?\d+(?:\.\d+)?)\s*([a-zA-Zµ%]*)$`)
// NutritionFromLines derives a panel from the catalogue's display strings.
//
// Returns nil when nothing usable is found, so a caller can tell "no nutrition"
// from "a panel with no numbers in it".
func NutritionFromLines(lines []string) *NutritionPanel {
items := make([]NutritionItem, 0, len(lines))
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
match := nutrientLine.FindStringSubmatch(line)
if match == nil {
// No number anywhere. Kept as written — see above.
items = append(items, NutritionItem{Name: line})
continue
}
name := strings.TrimSpace(strings.Trim(match[1], "-–—:"))
if name == "" {
// The whole line was a number. Nothing sensible to label it with,
// and an unnamed row on a nutrition panel is noise.
continue
}
value, err := strconv.ParseFloat(match[2], 64)
if err != nil {
items = append(items, NutritionItem{Name: line})
continue
}
items = append(items, NutritionItem{
Name: name,
Value: value,
Unit: strings.TrimSpace(match[3]),
})
}
if len(items) == 0 {
return nil
}
// No `per` and no serving size, deliberately left empty rather than guessed
// at. "100g" is the common case and it is not the only one, and a wrong
// basis is worse than an absent one — it makes every figure beneath it a
// misstatement rather than an unknown.
return &NutritionPanel{Items: items}
}

141
models/nutrition_test.go Normal file
View File

@@ -0,0 +1,141 @@
package models
import "testing"
/*
Reading the catalogue's nutrition strings.
The catalogue holds nutrition as display lines — "Energy 350kcal" — and the app
needs figures. These are the shapes those lines actually come in, and the ones
they come in when a scrape goes sideways.
The rule throughout: never invent a number, and never lose label text. A line
that cannot be read is carried whole rather than dropped, because it is
something a manufacturer printed on a packet and this code is not the authority
on what belongs on a food label.
*/
func TestAPlainLineBecomesAFigure(t *testing.T) {
panel := NutritionFromLines([]string{"Energy 350kcal"})
if !panel.HasValues() {
t.Fatal("nothing parsed")
}
got := panel.Items[0]
if got.Name != "Energy" || got.Value != 350 || got.Unit != "kcal" {
t.Fatalf("got %+v", got)
}
}
func TestTheSeparatorsThatActuallyOccur(t *testing.T) {
// Colons, multi-word names and a space before the unit are all in the wild,
// and each one used to be a whole line lost.
for _, tc := range []struct {
line string
name string
value float64
unit string
}{
{"Protein: 6.5 g", "Protein", 6.5, "g"},
{"Total Sugars 22g", "Total Sugars", 22, "g"},
{"Saturated Fat 11.5 g", "Saturated Fat", 11.5, "g"},
{"Sodium 310mg", "Sodium", 310, "mg"},
{"Energy - 520 kcal", "Energy", 520, "kcal"},
} {
panel := NutritionFromLines([]string{tc.line})
if !panel.HasValues() {
t.Errorf("%q parsed to nothing", tc.line)
continue
}
got := panel.Items[0]
if got.Name != tc.name || got.Value != tc.value || got.Unit != tc.unit {
t.Errorf("%q → %+v, want {%s %v %s}", tc.line, got, tc.name, tc.value, tc.unit)
}
}
}
func TestADecimalSurvives(t *testing.T) {
// Saturated fat is 11.5g as often as 11g. Rounding to please a type would be
// editing a food label.
panel := NutritionFromLines([]string{"Saturated Fat 11.5g"})
if panel.Items[0].Value != 11.5 {
t.Fatalf("got %v, want 11.5", panel.Items[0].Value)
}
}
func TestALineWithNoNumberIsKeptWhole(t *testing.T) {
// Real label text. Dropping it loses something a manufacturer printed;
// forcing a 0 into it states something false about the food.
panel := NutritionFromLines([]string{"Contains permitted natural colour"})
if !panel.HasValues() {
t.Fatal("the line was dropped")
}
got := panel.Items[0]
if got.Name != "Contains permitted natural colour" {
t.Fatalf("the text was mangled: %+v", got)
}
if got.Value != 0 || got.Unit != "" {
t.Fatalf("a figure was invented for a line that had none: %+v", got)
}
}
func TestAUnitlessFigureKeepsItsNumber(t *testing.T) {
// "Servings per pack 4" has a real number and no unit.
panel := NutritionFromLines([]string{"Servings per pack 4"})
got := panel.Items[0]
if got.Name != "Servings per pack" || got.Value != 4 || got.Unit != "" {
t.Fatalf("got %+v", got)
}
}
func TestPercentAndMicrogramsAreUnits(t *testing.T) {
for _, tc := range []struct{ line, unit string }{
{"Vitamin C 45%", "%"},
{"Vitamin B12 1.2µg", "µg"},
} {
panel := NutritionFromLines([]string{tc.line})
if !panel.HasValues() || panel.Items[0].Unit != tc.unit {
t.Errorf("%q → %+v, want unit %q", tc.line, panel.Items[0], tc.unit)
}
}
}
func TestBlanksAndBareNumbersAreNotRows(t *testing.T) {
// A blank is nothing. A bare "100" has no label, and an unnamed row on a
// nutrition panel is noise a shopper cannot use.
if panel := NutritionFromLines([]string{"", " ", "100"}); panel != nil {
t.Fatalf("made a panel out of nothing: %+v", panel)
}
}
func TestNoLinesMeansNoPanel(t *testing.T) {
// nil rather than an empty panel, so a caller can tell "no nutrition known"
// from "a panel that happens to be empty" — the endpoint omits the first.
if NutritionFromLines(nil) != nil {
t.Fatal("an absent panel was reported as present")
}
if NutritionFromLines([]string{}) != nil {
t.Fatal("an absent panel was reported as present")
}
}
func TestAnEmptyPanelIsNotWorthSending(t *testing.T) {
// `items: []` on a product page renders as an empty box, which reads as
// "this food has no nutrition" rather than "we do not know yet".
var absent *NutritionPanel
if absent.HasValues() {
t.Fatal("nil reported as having values")
}
if (&NutritionPanel{Per: "100g"}).HasValues() {
t.Fatal("a panel with a basis and no rows reported as having values")
}
}
func TestTheDerivedPanelDoesNotGuessItsBasis(t *testing.T) {
// The strings never carried one. "100g" is the common case and not the only
// one, and a wrong basis makes every figure beneath it a misstatement rather
// than an unknown.
panel := NutritionFromLines([]string{"Energy 350kcal"})
if panel.Per != "" || panel.Servingsize != "" {
t.Fatalf("invented a basis: per=%q servingsize=%q", panel.Per, panel.Servingsize)
}
}

View File

@@ -172,6 +172,20 @@ type Products struct {
// scan-into-struct silently drops slice- and map-kind destination fields.
Cataloguefacts string `json:"cataloguefacts,omitempty" gorm:"column:cataloguefacts;type:jsonb"`
// The nutrition panel, for the product screen in the customer app.
//
// `gorm:"-"`: not a column. It is unpacked from Cataloguefacts above, which
// is where the import snapshots it — a second column holding the same facts
// is a second thing to keep in step, and this one has no writer of its own.
//
// ABSENT rather than null when a product has no nutrition. Most products on
// the platform have none today, and `"nutrition": null` on every row of a
// mobile response is payload spent saying nothing. An app should read a
// missing key as "not known", never as "this food has no nutrition".
//
// Set by the service, not the repository — see decorateNutrition.
Nutrition *NutritionPanel `json:"nutrition,omitempty" gorm:"-"`
Productdesc string `json:"productdesc,omitempty"`
Productsku string `json:"productsku,omitempty"`
Brandid int `json:"brandid,omitempty"`
@@ -226,22 +240,22 @@ type Products struct {
}
type Locationproducts struct {
Productid int `json:"productid"`
AppLocationid int `json:"applocationid" gorm:"column:applocationid"`
Productlocationid int `json:"productlocationid" gorm:"->"`
Tenantid int `json:"tenantid,omitempty"`
Categoryid int `json:"categoryid"`
Categoryname string `json:"categoryname" gorm:"->"`
Subcategoryid int `json:"subcategoryid,omitempty"`
Subcategoryname string `json:"Subcategoryname" gorm:"->"`
Catalogueid int `json:"catalogueid,omitempty"`
Addonid int `json:"addonid,omitempty"`
Discountid int `json:"discountid,omitempty"`
Pricingid int `json:"pricingid,omitempty"`
Productname string `json:"productname,omitempty"`
Productimage string `json:"productimage,omitempty"`
Productdesc string `json:"productdesc,omitempty"`
Productsku string `json:"productsku,omitempty"`
Productid int `json:"productid"`
AppLocationid int `json:"applocationid" gorm:"column:applocationid"`
Productlocationid int `json:"productlocationid" gorm:"->"`
Tenantid int `json:"tenantid,omitempty"`
Categoryid int `json:"categoryid"`
Categoryname string `json:"categoryname" gorm:"->"`
Subcategoryid int `json:"subcategoryid,omitempty"`
Subcategoryname string `json:"Subcategoryname" gorm:"->"`
Catalogueid int `json:"catalogueid,omitempty"`
Addonid int `json:"addonid,omitempty"`
Discountid int `json:"discountid,omitempty"`
Pricingid int `json:"pricingid,omitempty"`
Productname string `json:"productname,omitempty"`
Productimage string `json:"productimage,omitempty"`
Productdesc string `json:"productdesc,omitempty"`
Productsku string `json:"productsku,omitempty"`
// Three columns this read used to leave in the table.
//
@@ -264,19 +278,19 @@ type Locationproducts struct {
Productimages string `json:"productimages,omitempty"`
Cataloguefacts string `json:"cataloguefacts,omitempty"`
Brandid int `json:"brandid,omitempty"`
Productbrand string `json:"productbrand,omitempty"`
Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue"`
Toppicks string `json:"toppicks,omitempty"`
Productcost float64 `json:"productcost,omitempty"`
Taxamount float64 `json:"taxamount,omitempty"`
Taxpercent float64 `json:"taxpercent,omitempty"`
Producttax int `json:"producttax" gorm:"default:0"`
Productstock int `json:"productstock" gorm:"default:0"`
Productcombo int `json:"productcombo" gorm:"default:0"`
Variants int `json:"variants" gorm:"default:0"`
Quantity int `json:"quantity"`
Brandid int `json:"brandid,omitempty"`
Productbrand string `json:"productbrand,omitempty"`
Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue"`
Toppicks string `json:"toppicks,omitempty"`
Productcost float64 `json:"productcost,omitempty"`
Taxamount float64 `json:"taxamount,omitempty"`
Taxpercent float64 `json:"taxpercent,omitempty"`
Producttax int `json:"producttax" gorm:"default:0"`
Productstock int `json:"productstock" gorm:"default:0"`
Productcombo int `json:"productcombo" gorm:"default:0"`
Variants int `json:"variants" gorm:"default:0"`
Quantity int `json:"quantity"`
// Price is the per-store selling price from productlocations.price — the one
// CreateProductLocation upserts. Read-only here: it comes from the joined
// productlocations row, not from products. Without it a store could set a