199 lines
7.4 KiB
TypeScript
199 lines
7.4 KiB
TypeScript
/**
|
||
* 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];
|
||
}
|