health score

This commit is contained in:
2026-09-29 22:40:36 +05:30
parent f9fb405974
commit fb859ecda1
21 changed files with 1590 additions and 703 deletions

View File

@@ -50,10 +50,17 @@ type ProductService interface {
type productService struct {
repo repositories.ProductRepository
catalogueService CatalogueService
// May be nil. A deployment without the catalogue-intelligence service still
// serves every product screen, without a nutrition panel on it.
nutrition NutritionService
}
func NewProductService(repo repositories.ProductRepository, catalogueService CatalogueService) ProductService {
return &productService{repo: repo, catalogueService: catalogueService}
func NewProductService(
repo repositories.ProductRepository,
catalogueService CatalogueService,
nutrition NutritionService,
) ProductService {
return &productService{repo: repo, catalogueService: catalogueService, nutrition: nutrition}
}
func (s *productService) GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) {
@@ -251,7 +258,7 @@ func (s *productService) GetProductByVariant(tenantid, variantid, locationid, pr
// `productstock` on each row is what the app disables on. A single ungrouped
// product comes back as a one-member group, so the app has one code path:
// count the rows, and skip the picker when there is only one.
decorateNutrition(result)
decorateNutrition(s.nutrition, result)
return result, nil
}
@@ -375,19 +382,6 @@ func catalogueFactsOf(p *models.CatalogueProduct) map[string]any {
putList("highlights", p.Highlights)
putList("nutrients", p.Nutrients)
// The structured panel, snapshotted like everything else here.
//
// Kept BESIDE `nutrients` rather than instead of it. The lines are what the
// console has always shown and what a re-scrape can still change; the panel
// is what the app renders. Dropping either would break a reader that exists.
//
// `Nutrition` is already the resolved one — structured where the catalogue
// has it, derived from the lines where it does not — so a product imported
// today carries a panel whether or not the agent team has reached its brand.
if p.Nutrition.HasValues() {
facts["nutrition"] = p.Nutrition
}
return facts
}
@@ -634,61 +628,34 @@ func (s *productService) UnpublishProduct(tenantID, productID int) (int, error)
}
/*
decorateNutrition unpacks each product's nutrition panel for the app.
decorateNutrition attaches each product's nutrition panel.
── Why the endpoint does this and the query does not ───────────────────────
The panel lives inside `cataloguefacts`, a jsonb blob the import writes to hold
everything the catalogue knew that `products` has no column for. Reading it in
SQL would mean a jsonb path expression inside a query that already carries four
correlated subqueries, and it would have to be repeated in every read that ever
wants nutrition. Unpacking it once, here, keeps the query about the shelf.
The figures are not in Fiesta's database. They live in the catalogue-
intelligence service, keyed by brand and image_id — the two things a tenant's
product already carries from import — so there is nothing to join to and nothing
for the SQL to select. See nutritionService.go.
── The two shapes it accepts ───────────────────────────────────────────────
── Why a loop of network calls is acceptable here and nowhere else ─────────
`nutrition` is the structured panel and is used wherever it exists. `nutrients`
is the older text[] of display lines — "Energy 350kcal" — which is what every
product on the platform actually carries today, and it is parsed into the same
shape rather than left unreadable by the app.
This endpoint answers one tapped product plus its variant siblings: one row for
most products, a handful for a size family. Never a page of sixty. Each lookup
is cached for six hours and capped at three seconds, and a failure costs that
product its panel and nothing else.
So a product shows figures if ANYTHING is known about it, and the field is
absent only when genuinely nothing is.
── What it will not do ─────────────────────────────────────────────────────
It does not go back to the catalogue DB for a product whose snapshot is empty.
That is a cross-database lookup on a hot mobile endpoint, per product, on a
screen a shopper is waiting for. Products imported before `cataloguefacts`
existed have empty snapshots — measured: all 15 catalogue-imported products on
tenant 1147 — and the fix for those is the backfill in
`scratch/nutritionbackfill`, run once, not a lookup paid for on every tap.
It is deliberately NOT on the browse lists. Sixty products would be sixty
lookups on a screen that shows no nutrition anyway.
*/
func decorateNutrition(products []models.Products) {
func decorateNutrition(nutrition NutritionService, products []models.Products) {
if nutrition == nil {
return
}
for i := range products {
products[i].Nutrition = nutritionFromFacts(products[i].Cataloguefacts)
// One call per product, not two: the panel and the score come from the
// same upstream record.
found := nutrition.ForProduct(products[i].Productbrand, products[i].Imageid)
products[i].Nutrition = found.Panel
products[i].Healthscore = found.Health
}
}
// nutritionFromFacts reads a panel out of one product's catalogue snapshot.
//
// Every failure returns nil rather than an error: the product screen is worth
// drawing without a nutrition panel, and a malformed blob on one row must not
// fail the response for the variant group it belongs to.
func nutritionFromFacts(raw string) *models.NutritionPanel {
if strings.TrimSpace(raw) == "" {
return nil
}
var facts struct {
Nutrition *models.NutritionPanel `json:"nutrition"`
Nutrients []string `json:"nutrients"`
}
if err := json.Unmarshal([]byte(raw), &facts); err != nil {
return nil
}
if facts.Nutrition.HasValues() {
return facts.Nutrition
}
return models.NutritionFromLines(facts.Nutrients)
}