/** * The aisle the customer app actually displays, and how a category becomes one. * * ── What the app does ─────────────────────────────────────────────────────── * * Its browse screen calls * * GET /mob/products/getproductsbysubcategory?categoryid=2&tenantid=…&locationid=… * * and that endpoint does two things (`services/productService.go:237`): * * 1. `GetProducts` filters on `a.categoryid = ?` — UNCONDITIONALLY, with the * 2 from the query string. * 2. It then buckets the result by `products.subcategoryid`, against the rows * of `productsubcategories` for category 2. Anything with subcategoryid 0 * falls into a bucket literally named "Uncategorized". * * So the heading a shopper reads is the SUBCATEGORY, not the category — and * `categoryid` is not a label at all, it is the filter that decides whether the * product comes back. Measured on live tenant 1135/1166: every product returns * `categoryid: 2, subcategoryid: 0`, so the app renders a single heading, * "Uncategorized", holding the whole shop. That is exactly the report from the * app side, and it is why nothing displays category-wise. * * TWO CONSEQUENCES, both the opposite of what looks obvious: * * - `categoryid` must stay 2. Writing a per-product categoryid — 91 for * Dairy, 92 for Spices — does not label the product, it removes it: the app * asks for 2 and the row no longer matches. * - `subcategoryid` is what has to be written, and it can only be one of the * ten rows `productsubcategories` holds for category 2. Those are platform * rows shared by every tenant, not something a shop creates. * * ── Why the ids are looked up rather than hardcoded ───────────────────────── * * `productsubcategories` (subcatid 14-23) and the app's own utils list * (`/mob/utils/getsubcategories`, ids 9-18) carry the SAME TEN NAMES under * different ids. The grouping reads the first, so those are the ids to write — * but two tables one offset apart is precisely the shape that gets confused, so * the ids are read back by name from the same API the grouping uses, and * `FALLBACK_AISLE_IDS` is used only when that call cannot be made. */ import type { ProductSubCategory } from '@/api/types'; /** The ten aisles, in the order the app lists them. */ export const APP_AISLES = [ 'Vegetables & Fruits', 'Dairy, Deli & Egg', 'Meat, Chicken & Fish', 'Bakes & Nuts', 'Foodgrains & Pulses', 'Oil & Ghee', 'Rice & Cereals', 'Snacks & Drinks', 'Beauty & Personal Care', 'Hygiene Essentials', ] as const; export type AppAisle = (typeof APP_AISLES)[number]; /** * The ids as `productsubcategories` holds them today, read from live on * 2026-09-08. Used only when the lookup cannot be made — see the header. */ export const FALLBACK_AISLE_IDS: Record = { 'Vegetables & Fruits': 14, 'Dairy, Deli & Egg': 15, 'Meat, Chicken & Fish': 16, 'Bakes & Nuts': 17, 'Foodgrains & Pulses': 18, 'Oil & Ghee': 19, 'Rice & Cereals': 20, 'Snacks & Drinks': 21, 'Beauty & Personal Care': 22, 'Hygiene Essentials': 23, }; /** * The catalogue team's 31 categories, folded into the app's ten aisles. * * The 31 are a finer classification than the shop floor has room for, so this * is a real narrowing and some of it is judgement rather than fact: "Spices & * Masalas" has no aisle of its own and goes to Foodgrains & Pulses, the nearest * the app offers. Where a category could sit in two it goes to the one a * shopper would look in first — instant noodles to Snacks & Drinks rather than * Rice & Cereals, because that is where a shop stocks them. * * Anything not listed — including "General", the ladder's explicit "we do not * know" — has no aisle, and `aisleForCategory` returns null rather than * guessing. Those products land in the app's own "Uncategorized" bucket, which * is honest: it says the classification failed, instead of shelving bleach with * the butter. */ const AISLE_FOR_CATEGORY: Record = { /* Bakes & Nuts */ 'biscuits & cookies': 'Bakes & Nuts', rusk: 'Bakes & Nuts', crackers: 'Bakes & Nuts', 'cakes & muffins': 'Bakes & Nuts', 'bakery & breads': 'Bakes & Nuts', /* Snacks & Drinks */ snacks: 'Snacks & Drinks', chocolates: 'Snacks & Drinks', 'candy & confectionery': 'Snacks & Drinks', beverages: 'Snacks & Drinks', 'noodles & instant food': 'Snacks & Drinks', /* Foodgrains & Pulses */ 'pulses, grains & spices': 'Foodgrains & Pulses', 'spices & masalas': 'Foodgrains & Pulses', 'salt & staples': 'Foodgrains & Pulses', 'sugar & jaggery': 'Foodgrains & Pulses', /* Rice & Cereals */ 'atta & staples': 'Rice & Cereals', /* Oil & Ghee */ 'cooking oils': 'Oil & Ghee', /* Dairy, Deli & Egg */ dairy: 'Dairy, Deli & Egg', eggs: 'Dairy, Deli & Egg', /* Meat, Chicken & Fish */ 'fish & seafood': 'Meat, Chicken & Fish', /* Vegetables & Fruits */ 'fruits & vegetables': 'Vegetables & Fruits', 'fresh herbs & greens': 'Vegetables & Fruits', flowers: 'Vegetables & Fruits', /* Beauty & Personal Care */ 'oral care': 'Beauty & Personal Care', 'hair care': 'Beauty & Personal Care', 'bath soap': 'Beauty & Personal Care', 'skin & bath care': 'Beauty & Personal Care', 'fragrance & deodorants': 'Beauty & Personal Care', /* Hygiene Essentials */ 'household cleaning': 'Hygiene Essentials', 'health care - antiseptic': 'Hygiene Essentials', 'household - agarbatti': 'Hygiene Essentials', 'household - lamp oil': 'Hygiene Essentials', }; /** * Every dash normalised to a plain hyphen before lookup. * * The registry writes "Household – Agarbatti" with an en-dash and a sheet typed * by hand will not. Two strings that read identically must not reach different * aisles because of which key somebody pressed. */ function keyOf(category: string | null | undefined): string { return (category ?? '') .trim() .toLowerCase() .replace(/[‐-―]/g, '-') .replace(/\s*-\s*/g, ' - ') .replace(/\s+/g, ' '); } /** The aisle a classified category belongs in, or null when there is none. */ export function aisleForCategory(category: string | null | undefined): AppAisle | null { return AISLE_FOR_CATEGORY[keyOf(category)] ?? null; } /** * Aisle name to the id the app groups on, from the platform's own list. * * Matched on the NAME, because the id is exactly what differs between the two * tables carrying these ten rows. A name the API does not return has no id, and * the caller writes 0 rather than a number out of the wrong table. */ export function aisleIdsFrom( subcategories: readonly ProductSubCategory[] | undefined, ): Map { const out = new Map(); for (const row of subcategories ?? []) { const name = (row.subcatname ?? '').trim(); if (name && row.subcatid > 0) out.set(name.toLowerCase(), row.subcatid); } if (out.size === 0) { for (const [name, id] of Object.entries(FALLBACK_AISLE_IDS)) out.set(name.toLowerCase(), id); } return out; } /** The subcategory id for a classified category — 0 when it has no aisle. */ export function aisleIdForCategory( category: string | null | undefined, aisleIds: ReadonlyMap, ): number { const aisle = aisleForCategory(category); if (!aisle) return 0; return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle]; }