Files
daily_console_web/src/features/store-admin/appAisle.ts
2026-09-08 12:03:23 +05:30

199 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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];
}