category changes

This commit is contained in:
2026-09-08 12:03:23 +05:30
parent 1a37949bbe
commit d54fadef20
22 changed files with 776 additions and 540 deletions

View File

@@ -0,0 +1,198 @@
/**
* 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<AppAisle, number> = {
'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<string, AppAisle> = {
/* 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<string, number> {
const out = new Map<string, number>();
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<string, number>,
): number {
const aisle = aisleForCategory(category);
if (!aisle) return 0;
return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle];
}