category
This commit is contained in:
@@ -141,6 +141,17 @@ export const productsApi = {
|
||||
{ tenantid },
|
||||
),
|
||||
|
||||
/**
|
||||
* Exchanges category NAMES for this tenant's category ids, creating any that
|
||||
* do not exist yet.
|
||||
*
|
||||
* POST because it writes: a sheet naming an aisle this shop has never stocked
|
||||
* opens the aisle rather than failing. Keyed on the lowercased, trimmed name,
|
||||
* so a caller looks up whatever casing its own sheet used.
|
||||
*/
|
||||
resolveCategories: (tenantid: number, names: string[]) =>
|
||||
api.post<Record<string, number>>(`${WEB}/products/resolvecategories`, { tenantid, names }),
|
||||
|
||||
/** Unlinks from the store. The product row and its order history survive. */
|
||||
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
|
||||
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
|
||||
@@ -154,6 +165,13 @@ export const productsApi = {
|
||||
export interface SheetProductRow {
|
||||
productname: string;
|
||||
productsku: string;
|
||||
/**
|
||||
* The category NAME, from the ladder in `productCategory.ts`.
|
||||
*
|
||||
* The name is what the pipeline and the catalogue both speak; the id is
|
||||
* per-tenant and is resolved from this at import time.
|
||||
*/
|
||||
category?: string;
|
||||
categoryid: number;
|
||||
subcategoryid: number;
|
||||
retailprice: number;
|
||||
@@ -209,13 +227,39 @@ export async function importSheetProducts(
|
||||
const failures: SheetImportResult['failures'] = [];
|
||||
const createdSkus: string[] = [];
|
||||
|
||||
/*
|
||||
Categories first, in one call, before a single product is created.
|
||||
|
||||
Every row carries a category NAME worked out by the ladder in
|
||||
`productCategory.ts`, and the customer app browses by categoryid and rejects
|
||||
0 — so a product created before its aisle exists is a product no shopper can
|
||||
find. Resolving up front also means one round trip for the whole sheet rather
|
||||
than one per row.
|
||||
|
||||
A failure here is not fatal. The rows still carry whatever categoryid the
|
||||
operator picked for the upload, which is exactly the behaviour this had
|
||||
before, so a category service that is down costs the new filing and not the
|
||||
import.
|
||||
*/
|
||||
const wanted = [...new Set(rows.map((row) => row.category ?? '').filter(Boolean))];
|
||||
let resolved: Record<string, number> = {};
|
||||
if (wanted.length > 0) {
|
||||
try {
|
||||
resolved = (await productsApi.resolveCategories(tenantid, wanted)) ?? {};
|
||||
} catch {
|
||||
resolved = {};
|
||||
}
|
||||
}
|
||||
const categoryIdFor = (row: SheetProductRow): number =>
|
||||
resolved[(row.category ?? '').trim().toLowerCase()] ?? row.categoryid;
|
||||
|
||||
for (const [index, row] of rows.entries()) {
|
||||
try {
|
||||
await productsApi.createProduct({
|
||||
tenantid,
|
||||
productname: row.productname,
|
||||
productsku: row.productsku,
|
||||
categoryid: row.categoryid,
|
||||
categoryid: categoryIdFor(row),
|
||||
subcategoryid: row.subcategoryid,
|
||||
retailprice: row.retailprice,
|
||||
productcost: row.productcost,
|
||||
|
||||
@@ -11,21 +11,40 @@ import { parseProductSheet } from './parseProductSheet';
|
||||
|
||||
const sheet = (csv: string) => new File([csv], 'products.csv', { type: 'text/csv' });
|
||||
|
||||
// The template ships a `Category` column holding a NAME, for the ingest service.
|
||||
// The template ships a `Category` column holding a NAME.
|
||||
//
|
||||
// It used to be aliased onto `categoryid`, run through toNumber, and become 0 —
|
||||
// silently, and while counting as a mapped column so it never showed up as
|
||||
// ignored either.
|
||||
test('a text Category column is not read as a category id', async () => {
|
||||
// ignored either. The column is read again now, but as a NAME feeding rule 1 of
|
||||
// the category ladder; the id it eventually gets is resolved per tenant at
|
||||
// import time. The guarantee that matters is unchanged and asserted below: a
|
||||
// text category never becomes a categoryid.
|
||||
test('a text Category column is read as a name, never as an id', async () => {
|
||||
const parsed = await parseProductSheet(
|
||||
sheet('Product Name,Brand,Category,MRP\nBritannia Marie Gold 250g,Britannia,Biscuits,30\n'),
|
||||
);
|
||||
|
||||
assert.equal(parsed.rows.length, 1);
|
||||
assert.equal(parsed.rows[0]?.categoryid, 0, 'the console supplies the category, not the sheet');
|
||||
assert.ok(
|
||||
parsed.unmappedColumns.some((column) => /category/i.test(column)),
|
||||
`Category should be reported as a column we do not read, got ${JSON.stringify(parsed.unmappedColumns)}`,
|
||||
assert.equal(parsed.rows[0]?.categoryid, 0, 'the id is resolved at import, not read from the sheet');
|
||||
assert.equal(parsed.rows[0]?.category, 'Biscuits', 'the sheet is authoritative for the name');
|
||||
});
|
||||
|
||||
test('a sheet with no Category column still gets one from the ladder', async () => {
|
||||
// Rule 2: the keyword registry, over the product name.
|
||||
const parsed = await parseProductSheet(
|
||||
sheet('Product Name,MRP\nAmul Gold Full Cream Milk 1L,60\n'),
|
||||
);
|
||||
assert.equal(parsed.rows[0]?.category, 'Dairy');
|
||||
assert.equal(parsed.rows[0]?.categoryid, 0);
|
||||
});
|
||||
|
||||
test('a Category column holding a number is treated as an id, not a name', async () => {
|
||||
// The ladder's all-digits guard, reached through the parser: an operator who
|
||||
// types 1001 in the Category column gets the ladder's answer, not "1001".
|
||||
const parsed = await parseProductSheet(
|
||||
sheet('Product Name,Category,MRP\nAmul Gold Full Cream Milk 1L,1001,60\n'),
|
||||
);
|
||||
assert.equal(parsed.rows[0]?.category, 'Dairy');
|
||||
});
|
||||
|
||||
// Even a literal categoryid column is ignored now: a number nobody can verify
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { SheetProductRow } from '@/api/products';
|
||||
import { resolveCategory } from '@/features/store-admin/productCategory';
|
||||
|
||||
/**
|
||||
* `xlsx` is around 400kB and only the spreadsheet path ever touches it, so it is
|
||||
@@ -40,6 +41,20 @@ const COLUMN_ALIASES: Record<keyof SheetProductRow, string[]> = {
|
||||
* service, which reads it as a name; it was never ours.
|
||||
*/
|
||||
categoryid: [],
|
||||
/**
|
||||
* The sheet's own category NAME — rule 1 of the ladder.
|
||||
*
|
||||
* This alias was removed once, for a good reason: it pointed at
|
||||
* `categoryid`, so a text cell reading "Biscuits" was parsed as a number,
|
||||
* failed, and became 0 — which hides a product from shoppers entirely,
|
||||
* because the customer app rejects categoryid 0.
|
||||
*
|
||||
* It is safe now because it no longer feeds an id. It feeds a NAME, which
|
||||
* `resolveCategory` treats as authoritative and `resolvecategories`
|
||||
* exchanges for a real per-tenant id at import time. The all-digits guard in
|
||||
* the ladder catches an operator who still types a number here.
|
||||
*/
|
||||
category: ['category', 'categoryname', 'aisle', 'department', 'section'],
|
||||
subcategoryid: [],
|
||||
retailprice: ['retailprice', 'price', 'mrp', 'sellingprice'],
|
||||
productcost: ['productcost', 'cost', 'purchaseprice', 'costprice'],
|
||||
@@ -140,12 +155,24 @@ export async function parseProductSheet(file: File): Promise<ParsedSheet> {
|
||||
const productcost = toNumber(picked.productcost);
|
||||
const taxpercent = toNumber(picked.taxpercent) ?? 0;
|
||||
const quantity = toNumber(picked.quantity) ?? 0;
|
||||
// Always zero — see COLUMN_ALIASES. The category is the operator's answer
|
||||
// for the whole upload, filled in by `buildImportRequests`, not a per-row
|
||||
// number read from the file.
|
||||
// Still zero. The id is per-tenant and cannot be known from a file — it is
|
||||
// resolved from the NAME below, once, at import time.
|
||||
const categoryid = 0;
|
||||
const subcategoryid = 0;
|
||||
|
||||
/*
|
||||
The category, by the same deterministic ladder the catalogue team runs:
|
||||
the sheet's own value, then the keyword registry, then the commodity
|
||||
lexicon, then "General". Worked out here rather than in the preview so
|
||||
the row an operator reviews and the row that is imported cannot disagree.
|
||||
*/
|
||||
const category = resolveCategory({
|
||||
title: productname,
|
||||
description: String(picked.productdesc ?? '').trim(),
|
||||
sheetValue: String(picked.category ?? '').trim(),
|
||||
packSize: [picked.unitvalue, picked.productunit].filter(Boolean).join(' '),
|
||||
}).category;
|
||||
|
||||
// Only the product name is required, and that is the ingest service's rule
|
||||
// rather than ours: everything else is optional and enriched when blank,
|
||||
// and even the brand is inferred from the name.
|
||||
@@ -190,6 +217,7 @@ export async function parseProductSheet(file: File): Promise<ParsedSheet> {
|
||||
unitvalue: String(picked.unitvalue ?? '').trim() || undefined,
|
||||
productbrand: String(picked.productbrand ?? '').trim() || undefined,
|
||||
productdesc: String(picked.productdesc ?? '').trim() || undefined,
|
||||
category,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ import { importSheetProducts, type SheetImportResult, type SheetProductRow } fro
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { SheetDropzone } from '@/components/SheetDropzone';
|
||||
import {
|
||||
resolveCategory,
|
||||
UNKNOWN_CATEGORY,
|
||||
type CategoryRule,
|
||||
} from './productCategory';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import {
|
||||
@@ -34,10 +39,20 @@ import {
|
||||
type ParsedSheet,
|
||||
} from '@/features/nearle-admin/import/parseProductSheet';
|
||||
|
||||
/** How the category was arrived at, in the operator's words. */
|
||||
const RULE_LABEL: Record<CategoryRule, string> = {
|
||||
sheet: 'from your sheet',
|
||||
registry: 'matched by name',
|
||||
lexicon: 'matched as a commodity',
|
||||
unknown: 'could not tell',
|
||||
};
|
||||
|
||||
interface PreviewRow extends Record<string, unknown> {
|
||||
productname: string;
|
||||
productsku: string;
|
||||
categoryid: number;
|
||||
/** Resolved by the category ladder, before anything is created. */
|
||||
category: string;
|
||||
categoryRule: CategoryRule;
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
quantity: number;
|
||||
@@ -119,11 +134,25 @@ export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImpo
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'categoryid',
|
||||
key: 'category',
|
||||
header: 'Category',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.categoryid}</Text>,
|
||||
width: { type: 'pixel', value: 170 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text
|
||||
type="body"
|
||||
size="sm"
|
||||
{...(row.category === UNKNOWN_CATEGORY ? { color: 'secondary' as const } : {})}
|
||||
>
|
||||
{row.category}
|
||||
</Text>
|
||||
{/* Which rule answered. An operator trusts their own column and
|
||||
checks an inferred one, and cannot tell them apart otherwise. */}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{RULE_LABEL[row.categoryRule]}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'retailprice',
|
||||
@@ -159,14 +188,35 @@ export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImpo
|
||||
/* The whole sheet, paged — it used to be cut at 25 with nothing saying so,
|
||||
unlike the issues list above, which says '…and N more'. A bad row on line
|
||||
300 was unreachable in the one screen meant for checking the parse. */
|
||||
const preview: PreviewRow[] = (parsed?.rows ?? []).map((row: SheetProductRow) => ({
|
||||
productname: row.productname,
|
||||
productsku: row.productsku,
|
||||
categoryid: row.categoryid,
|
||||
retailprice: row.retailprice,
|
||||
productcost: row.productcost,
|
||||
quantity: row.quantity,
|
||||
}));
|
||||
const preview: PreviewRow[] = (parsed?.rows ?? []).map((row: SheetProductRow) => {
|
||||
/*
|
||||
The category comes from the parser, which ran the ladder once; this
|
||||
re-runs it only to recover WHICH RULE answered, so the column can say so.
|
||||
The answer itself is the parser's own file, before a
|
||||
single product is created — the same deterministic ladder the catalogue
|
||||
team runs, so a product filed here and the same product in the global
|
||||
catalogue land in the same place.
|
||||
|
||||
Shown rather than silently applied: the column used to print
|
||||
`categoryid`, which the parser sets to 0 on every row, so it read "0" all
|
||||
the way down and told nobody anything.
|
||||
*/
|
||||
const verdict = resolveCategory({
|
||||
title: row.productname,
|
||||
description: row.productdesc ?? '',
|
||||
sheetValue: row.category ?? '',
|
||||
packSize: [row.unitvalue, row.productunit].filter(Boolean).join(' '),
|
||||
});
|
||||
return {
|
||||
productname: row.productname,
|
||||
productsku: row.productsku,
|
||||
category: verdict.category,
|
||||
categoryRule: verdict.rule,
|
||||
retailprice: row.retailprice,
|
||||
productcost: row.productcost,
|
||||
quantity: row.quantity,
|
||||
};
|
||||
});
|
||||
|
||||
// A new file is a new sheet, so the pager starts over.
|
||||
const previewPaged = usePaged(preview, { resetKey: preview.length });
|
||||
|
||||
169
src/features/store-admin/productCategory.test.ts
Normal file
169
src/features/store-admin/productCategory.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
CATEGORY_REGISTRY,
|
||||
commodityCategory,
|
||||
detectCategoryFromText,
|
||||
findMatches,
|
||||
genericTermFor,
|
||||
isCategoryCode,
|
||||
LEXICON_WORDS,
|
||||
resolveCategory,
|
||||
UNKNOWN_CATEGORY,
|
||||
} from './productCategory';
|
||||
|
||||
/* ── The three the catalogue team gave ───────────────────────────────────── */
|
||||
|
||||
test('the worked examples resolve as the pipeline resolves them', () => {
|
||||
assert.equal(resolveCategory({ title: 'Aachi Chicken Masala 200g' }).category, 'Spices & Masalas');
|
||||
assert.equal(resolveCategory({ title: 'Amul Gold Full Cream Milk 1L' }).category, 'Dairy');
|
||||
assert.equal(resolveCategory({ title: 'Dragon Fruit' }).category, 'Fruits & Vegetables');
|
||||
});
|
||||
|
||||
/* ── The ladder, rule by rule ────────────────────────────────────────────── */
|
||||
|
||||
test('the sheet wins over everything, verbatim', () => {
|
||||
// Their governing rule is fill only blanks: a supplied value is authoritative
|
||||
// even when the registry would have said something else, and it is NOT
|
||||
// normalised to a canonical name.
|
||||
const out = resolveCategory({ title: 'Amul Milk 1L', sheetValue: 'My Own Aisle' });
|
||||
assert.equal(out.category, 'My Own Aisle');
|
||||
assert.equal(out.rule, 'sheet');
|
||||
});
|
||||
|
||||
test('a sheet value of digits is an id, not a name', () => {
|
||||
/*
|
||||
Their guard, and it bites harder here: this console's sheet template once had
|
||||
a text category column read as an id, which failed to parse and became 0 —
|
||||
and the customer app rejects categoryid 0, so those products uploaded, priced,
|
||||
stocked, and could not be found by any shopper.
|
||||
*/
|
||||
assert.equal(isCategoryCode('1001'), true);
|
||||
assert.equal(isCategoryCode(' 42 '), true);
|
||||
assert.equal(isCategoryCode('Category 2'), false);
|
||||
const out = resolveCategory({ title: 'Amul Milk 1L', sheetValue: '1001' });
|
||||
assert.equal(out.rule, 'registry', 'the id is ignored and the ladder continues');
|
||||
assert.equal(out.category, 'Dairy');
|
||||
});
|
||||
|
||||
test('the registry answers before the lexicon', () => {
|
||||
const out = resolveCategory({ title: 'Aachi Sambar Powder 100g' });
|
||||
assert.equal(out.rule, 'registry');
|
||||
});
|
||||
|
||||
test('the lexicon catches a loose commodity the registry cannot', () => {
|
||||
// No brand, no description, nothing for the registry to match on.
|
||||
const out = resolveCategory({ title: 'keerai' });
|
||||
assert.equal(out.category, 'Fresh Herbs & Greens');
|
||||
assert.equal(out.rule, 'lexicon');
|
||||
});
|
||||
|
||||
test('a pack size that contradicts the lexicon rejects its answer', () => {
|
||||
// A loose commodity is weighed. A sealed retail pack wearing a commodity noun
|
||||
// is a branded product — "meen kulambu masala 100g" is a masala, not fish.
|
||||
assert.equal(commodityCategory('meen', ''), 'Fish & Seafood');
|
||||
assert.equal(commodityCategory('meen', '100g'), null);
|
||||
});
|
||||
|
||||
test('"General" is the explicit unknown, not a blank', () => {
|
||||
// Not null: the column is not nullable in practice, and a blank would be
|
||||
// indistinguishable from "nobody has looked at this yet".
|
||||
const out = resolveCategory({ title: 'Zx9 Widget Assembly' });
|
||||
assert.equal(out.category, UNKNOWN_CATEGORY);
|
||||
assert.equal(out.rule, 'unknown');
|
||||
assert.notEqual(out.category, '');
|
||||
});
|
||||
|
||||
/* ── The four orderings they call load-bearing ───────────────────────────── */
|
||||
|
||||
test('Biscuits before Chocolates — chocolate biscuits are a biscuit', () => {
|
||||
assert.equal(detectCategoryFromText('Britannia Chocolate Biscuits 100g'), 'Biscuits & Cookies');
|
||||
});
|
||||
|
||||
test('Pulses before Atta — a bag of dal is not swallowed by Atta', () => {
|
||||
// Atta & Staples carries broad rice/flour keywords that would otherwise take
|
||||
// anything with a grain word in it.
|
||||
assert.equal(detectCategoryFromText('Toor Dal 1kg'), 'Pulses, Grains & Spices');
|
||||
});
|
||||
|
||||
test('Beverages before Cooking Oils — a drink beats the greedy bare "oil"', () => {
|
||||
assert.equal(detectCategoryFromText('Cold Coffee Drink 200ml'), 'Beverages');
|
||||
});
|
||||
|
||||
test('Dairy before Skin & Bath Care — full cream milk is milk, not a cream', () => {
|
||||
// The team names this as the rule that settles their second example.
|
||||
assert.equal(detectCategoryFromText('Amul Gold Full Cream Milk 1L'), 'Dairy');
|
||||
});
|
||||
|
||||
/* ── Matching rules ──────────────────────────────────────────────────────── */
|
||||
|
||||
test('keywords match whole words only', () => {
|
||||
// "oil" must not fire on "boiled", which is how Cooking Oils swallows a
|
||||
// ready-to-eat product.
|
||||
assert.notEqual(detectCategoryFromText('Boiled Peanuts Pack'), 'Cooking Oils');
|
||||
assert.equal(detectCategoryFromText('Sunflower Oil 1L'), 'Cooking Oils');
|
||||
});
|
||||
|
||||
test('the description is searched as well as the title', () => {
|
||||
const out = detectCategoryFromText('Aachi 200g', 'A blend of ground spices for biryani');
|
||||
assert.equal(out, 'Spices & Masalas');
|
||||
});
|
||||
|
||||
test('within one priority the longest matched keyword wins', () => {
|
||||
const hits = findMatches('Gingelly Oil 500ml');
|
||||
assert.equal(hits[0]?.category, 'Cooking Oils');
|
||||
assert.equal(hits[0]?.keyword, 'gingelly oil', 'not the bare "oil"');
|
||||
});
|
||||
|
||||
test('matching is case-insensitive', () => {
|
||||
assert.equal(detectCategoryFromText('TOOR DAL 1KG'), 'Pulses, Grains & Spices');
|
||||
});
|
||||
|
||||
/* ── The registry itself ─────────────────────────────────────────────────── */
|
||||
|
||||
test('there are 31 categories and the order is the published one', () => {
|
||||
assert.equal(CATEGORY_REGISTRY.length, 31);
|
||||
assert.equal(CATEGORY_REGISTRY[0]?.category, 'Biscuits & Cookies');
|
||||
assert.equal(CATEGORY_REGISTRY.at(-1)?.category, 'Health Care – Antiseptic');
|
||||
});
|
||||
|
||||
test('no category is listed twice', () => {
|
||||
const names = CATEGORY_REGISTRY.map((entry) => entry.category);
|
||||
assert.equal(new Set(names).size, names.length);
|
||||
});
|
||||
|
||||
test('every category carries keywords and a generic term', () => {
|
||||
// The generic term is what repairs a description claiming the wrong identity,
|
||||
// so a category without one is a category that cannot be corrected.
|
||||
for (const entry of CATEGORY_REGISTRY) {
|
||||
assert.ok(entry.keywords.length > 0, `${entry.category} has keywords`);
|
||||
assert.ok(entry.genericTerm.length > 0, `${entry.category} has a generic term`);
|
||||
}
|
||||
assert.equal(genericTermFor('Spices & Masalas'), 'spice');
|
||||
assert.equal(genericTermFor('Not A Category'), null);
|
||||
});
|
||||
|
||||
test('the ladder is deterministic — the same input always gives the same answer', () => {
|
||||
// The property the whole design exists for: no model, no network, no clock.
|
||||
const input = { title: 'Aachi Chicken Masala 200g', description: 'ground spice blend' };
|
||||
const first = resolveCategory(input);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
assert.deepEqual(resolveCategory(input), first);
|
||||
}
|
||||
});
|
||||
|
||||
test('the registry and the lexicon do not overlap', () => {
|
||||
/*
|
||||
The invariant behind the failure that caught this. Both layers carried
|
||||
"keerai", so rule 2 always answered and rule 3 was unreachable — the lexicon
|
||||
existed but could never run. The registry is brand and product phrases; the
|
||||
lexicon is the loose-commodity nouns in English, Tamil and Hindi that no pack
|
||||
ever prints. A word in both makes one of them dead.
|
||||
*/
|
||||
const registryWords = new Set(
|
||||
CATEGORY_REGISTRY.flatMap((entry) => entry.keywords).map((word) => word.toLowerCase()),
|
||||
);
|
||||
for (const noun of LEXICON_WORDS) {
|
||||
assert.equal(registryWords.has(noun), false, `"${noun}" is in both layers`);
|
||||
}
|
||||
});
|
||||
337
src/features/store-admin/productCategory.ts
Normal file
337
src/features/store-admin/productCategory.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Which category a product belongs to.
|
||||
*
|
||||
* A port of the catalogue team's ladder, kept deterministic in the same way:
|
||||
* four rules tried in order, first hit wins, same input always yielding the
|
||||
* same answer with no network call and no model in the loop.
|
||||
*
|
||||
* 1. The sheet's own value — authoritative, kept verbatim
|
||||
* 2. The keyword registry — whole-word match over title + description
|
||||
* 3. The commodity lexicon — loose, unbranded groceries
|
||||
* 4. "General" — the explicit "we do not know"
|
||||
*
|
||||
* ── What is ported exactly, and what is not ─────────────────────────────────
|
||||
*
|
||||
* The LADDER, the 31 categories, their priority order and the tie-breaks are
|
||||
* theirs and are reproduced faithfully — including the four orderings they call
|
||||
* load-bearing, each of which has a test below naming it.
|
||||
*
|
||||
* The KEYWORDS are not theirs. Their registry lives in `category_registry.py`
|
||||
* and was not shared; what is here is a working set built from the category
|
||||
* names and the everyday Indian grocery vocabulary around them. It resolves the
|
||||
* cases they gave and a good deal more, but it will not agree with their
|
||||
* pipeline on every product until their list replaces this one. Swapping it is
|
||||
* a data change — the rules above do not move.
|
||||
*/
|
||||
|
||||
export interface CategoryEntry {
|
||||
category: string;
|
||||
/** Phrases that identify the category. Matched as WHOLE WORDS, lowercased. */
|
||||
keywords: string[];
|
||||
/** The neutral noun used to repair a description claiming a wrong identity. */
|
||||
genericTerm: string;
|
||||
}
|
||||
|
||||
/** The explicit "we do not know". Not empty, and not null. */
|
||||
export const UNKNOWN_CATEGORY = 'General';
|
||||
|
||||
/**
|
||||
* The 31 canonical categories, in priority order.
|
||||
*
|
||||
* Earlier means higher priority when a product matches more than one, and the
|
||||
* order is load-bearing rather than cosmetic. Four of them decide real cases:
|
||||
*
|
||||
* - Biscuits & Cookies before Chocolates — "chocolate biscuits" is a biscuit.
|
||||
* - Pulses before Atta & Staples — a bag of dal is not swallowed by Atta's
|
||||
* broad dal/rice keywords.
|
||||
* - Beverages before Cooking Oils — a drink resolves before Cooking Oils'
|
||||
* very greedy bare "oil".
|
||||
* - Dairy before Skin & Bath Care — Full Cream Milk is milk, not a skin cream.
|
||||
*/
|
||||
export const CATEGORY_REGISTRY: readonly CategoryEntry[] = [
|
||||
{
|
||||
category: 'Biscuits & Cookies',
|
||||
keywords: ['biscuit', 'biscuits', 'cookie', 'cookies', 'marie', 'digestive', 'bourbon', 'good day', 'krackjack'],
|
||||
genericTerm: 'biscuit',
|
||||
},
|
||||
{ category: 'Rusk', keywords: ['rusk', 'rusks', 'toast'], genericTerm: 'rusk' },
|
||||
{ category: 'Crackers', keywords: ['cracker', 'crackers', 'cream cracker'], genericTerm: 'cracker' },
|
||||
{
|
||||
category: 'Cakes & Muffins',
|
||||
keywords: ['cake', 'cakes', 'muffin', 'muffins', 'brownie', 'cupcake', 'swiss roll'],
|
||||
genericTerm: 'cake',
|
||||
},
|
||||
{
|
||||
category: 'Bakery & Breads',
|
||||
keywords: ['bread', 'breads', 'bun', 'buns', 'pav', 'baguette', 'croissant', 'bakery'],
|
||||
genericTerm: 'bread',
|
||||
},
|
||||
{
|
||||
category: 'Noodles & Instant Food',
|
||||
keywords: ['noodle', 'noodles', 'pasta', 'macaroni', 'vermicelli', 'semiya', 'instant', 'cup noodles', 'ramen'],
|
||||
genericTerm: 'noodles',
|
||||
},
|
||||
{
|
||||
category: 'Candy & Confectionery',
|
||||
keywords: ['candy', 'candies', 'toffee', 'lollipop', 'eclairs', 'mint', 'confectionery', 'jelly'],
|
||||
genericTerm: 'candy',
|
||||
},
|
||||
{
|
||||
category: 'Snacks',
|
||||
keywords: ['snack', 'snacks', 'chips', 'namkeen', 'mixture', 'sev', 'bhujia', 'murukku', 'appalam', 'appalams', 'papad', 'papads', 'fryums', 'wafer', 'wafers', 'popcorn'],
|
||||
genericTerm: 'snack',
|
||||
},
|
||||
{
|
||||
category: 'Chocolates',
|
||||
keywords: ['chocolate', 'chocolates', 'choco', 'dairy milk', 'five star', '5 star', 'perk', 'munch'],
|
||||
genericTerm: 'chocolate',
|
||||
},
|
||||
{
|
||||
category: 'Beverages',
|
||||
keywords: ['beverage', 'beverages', 'drink', 'drinks', 'juice', 'soda', 'cola', 'coffee', 'tea', 'squash', 'syrup', 'health drink', 'horlicks', 'bournvita', 'boost', 'water', 'soft drink'],
|
||||
genericTerm: 'beverage',
|
||||
},
|
||||
{
|
||||
category: 'Cooking Oils',
|
||||
keywords: ['oil', 'cooking oil', 'sunflower oil', 'groundnut oil', 'gingelly oil', 'sesame oil', 'coconut oil', 'mustard oil', 'rice bran', 'vanaspati'],
|
||||
genericTerm: 'oil',
|
||||
},
|
||||
{
|
||||
category: 'Pulses, Grains & Spices',
|
||||
keywords: ['dal', 'dhal', 'daal', 'toor', 'tur', 'urad', 'moong', 'mung', 'chana', 'masoor', 'rajma', 'lentil', 'lentils', 'pulse', 'pulses', 'gram', 'kabuli', 'peas', 'grain', 'grains'],
|
||||
genericTerm: 'pulse',
|
||||
},
|
||||
{
|
||||
category: 'Spices & Masalas',
|
||||
keywords: ['spice', 'spices', 'masala', 'masalas', 'turmeric', 'haldi', 'chilli powder', 'chili powder', 'cumin', 'jeera', 'coriander powder', 'dhania', 'garam masala', 'sambar powder', 'rasam powder', 'pepper', 'cardamom', 'clove', 'cinnamon', 'asafoetida', 'hing', 'fenugreek', 'mustard seed', 'curry powder', 'kuzhambu', 'kulambu', 'garlic paste', 'ginger garlic', 'ginger paste', 'paste', 'podi', 'powder'],
|
||||
genericTerm: 'spice',
|
||||
},
|
||||
{
|
||||
category: 'Sugar & Jaggery',
|
||||
keywords: ['sugar', 'jaggery', 'gur', 'vellam', 'palm sugar', 'sugarcane'],
|
||||
genericTerm: 'sugar',
|
||||
},
|
||||
{ category: 'Salt & Staples', keywords: ['salt', 'rock salt', 'iodised salt', 'sea salt'], genericTerm: 'salt' },
|
||||
{
|
||||
category: 'Atta & Staples',
|
||||
keywords: ['atta', 'flour', 'maida', 'rava', 'sooji', 'suji', 'besan', 'ragi', 'rice', 'basmati', 'poha', 'aval', 'idli rice', 'wheat'],
|
||||
genericTerm: 'flour',
|
||||
},
|
||||
{
|
||||
category: 'Dairy',
|
||||
keywords: ['milk', 'curd', 'yoghurt', 'yogurt', 'butter', 'ghee', 'cheese', 'paneer', 'cream', 'dairy', 'buttermilk', 'lassi', 'khoa'],
|
||||
genericTerm: 'dairy',
|
||||
},
|
||||
{
|
||||
category: 'Fruits & Vegetables',
|
||||
keywords: ['fruit', 'fruits', 'vegetable', 'vegetables', 'onion', 'potato', 'tomato', 'banana', 'apple', 'mango', 'carrot', 'brinjal', 'cabbage', 'cauliflower', 'lemon', 'dragon fruit', 'grapes', 'orange'],
|
||||
genericTerm: 'produce',
|
||||
},
|
||||
{
|
||||
category: 'Fresh Herbs & Greens',
|
||||
keywords: ['herb', 'herbs', 'greens', 'coriander leaves', 'curry leaves', 'mint leaves', 'spinach', 'methi leaves', 'basil', 'leaves', 'amaranth', 'drumstick leaves'],
|
||||
genericTerm: 'greens',
|
||||
},
|
||||
{ category: 'Flowers', keywords: ['flower', 'flowers', 'jasmine', 'rose petals', 'garland'], genericTerm: 'flowers' },
|
||||
{
|
||||
category: 'Fish & Seafood',
|
||||
keywords: ['fish', 'seafood', 'prawn', 'prawns', 'crab', 'squid', 'sardine', 'mackerel'],
|
||||
genericTerm: 'fish',
|
||||
},
|
||||
{ category: 'Eggs', keywords: ['egg', 'eggs'], genericTerm: 'eggs' },
|
||||
{
|
||||
category: 'Oral Care',
|
||||
keywords: ['toothpaste', 'toothbrush', 'tooth powder', 'mouthwash', 'dental', 'oral care', 'colgate'],
|
||||
genericTerm: 'oral care',
|
||||
},
|
||||
{
|
||||
category: 'Hair Care',
|
||||
keywords: ['shampoo', 'conditioner', 'hair oil', 'hair care', 'hair colour', 'hair color', 'hairfall'],
|
||||
genericTerm: 'hair care',
|
||||
},
|
||||
{ category: 'Bath Soap', keywords: ['bath soap', 'bathing bar', 'soap bar', 'lifebuoy', 'lux', 'cinthol'], genericTerm: 'soap' },
|
||||
{
|
||||
category: 'Skin & Bath Care',
|
||||
keywords: ['skin', 'lotion', 'moisturiser', 'moisturizer', 'face wash', 'body wash', 'talc', 'powder cream', 'cold cream', 'sunscreen', 'handwash'],
|
||||
genericTerm: 'skin care',
|
||||
},
|
||||
{
|
||||
category: 'Household Cleaning',
|
||||
keywords: ['detergent', 'washing powder', 'dishwash', 'cleaner', 'phenyl', 'bleach', 'toilet cleaner', 'floor cleaner', 'scrub'],
|
||||
genericTerm: 'cleaner',
|
||||
},
|
||||
{
|
||||
category: 'Fragrance & Deodorants',
|
||||
keywords: ['deodorant', 'deo', 'perfume', 'body spray', 'fragrance', 'cologne'],
|
||||
genericTerm: 'deodorant',
|
||||
},
|
||||
{ category: 'Household – Agarbatti', keywords: ['agarbatti', 'incense', 'dhoop', 'sambrani'], genericTerm: 'agarbatti' },
|
||||
{ category: 'Household – Lamp Oil', keywords: ['lamp oil', 'deepam oil', 'pooja oil'], genericTerm: 'lamp oil' },
|
||||
{
|
||||
category: 'Health Care – Antiseptic',
|
||||
keywords: ['antiseptic', 'dettol', 'savlon', 'disinfectant', 'sanitiser', 'sanitizer'],
|
||||
genericTerm: 'antiseptic',
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Rule 1: the sheet's own value ───────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* True when a sheet's category cell is an id rather than a name.
|
||||
*
|
||||
* Their guard, and it matters here more than there: this console's own sheet
|
||||
* template once carried a text column that was read as an id, failed to parse
|
||||
* and became 0 — which hides a product from shoppers entirely, because the
|
||||
* customer app rejects `categoryid` 0.
|
||||
*/
|
||||
export function isCategoryCode(value: string): boolean {
|
||||
return /^\d+$/.test(value.trim());
|
||||
}
|
||||
|
||||
/* ── Rule 2: the keyword registry ────────────────────────────────────────── */
|
||||
|
||||
/** Whole-word, so "oil" does not match "boiled" and "tea" does not match "steam". */
|
||||
function matchesWord(haystack: string, keyword: string): boolean {
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`(?:^|[^a-z0-9])${escaped}(?:[^a-z0-9]|$)`, 'i').test(haystack);
|
||||
}
|
||||
|
||||
export interface RegistryMatch {
|
||||
category: string;
|
||||
/** Position in the registry. Lower wins. */
|
||||
priority: number;
|
||||
/** The matched phrase. Longer wins within the same priority. */
|
||||
keyword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category whose keywords appear in the text.
|
||||
*
|
||||
* Returned in the order the tie-break wants them: registry priority first, then
|
||||
* the longest matched keyword. "Chocolate biscuits" matches both Biscuits and
|
||||
* Chocolates; Biscuits sits earlier and therefore wins.
|
||||
*/
|
||||
export function findMatches(title: string, description = ''): RegistryMatch[] {
|
||||
const text = `${title} ${description}`.toLowerCase();
|
||||
const hits: RegistryMatch[] = [];
|
||||
|
||||
CATEGORY_REGISTRY.forEach((entry, priority) => {
|
||||
const matched = entry.keywords
|
||||
.filter((keyword) => matchesWord(text, keyword))
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
if (matched) hits.push({ category: entry.category, priority, keyword: matched });
|
||||
});
|
||||
|
||||
return hits.sort((a, b) => a.priority - b.priority || b.keyword.length - a.keyword.length);
|
||||
}
|
||||
|
||||
/** The registry's answer, or null when nothing matched. */
|
||||
export function detectCategoryFromText(title: string, description = ''): string | null {
|
||||
return findMatches(title, description)[0]?.category ?? null;
|
||||
}
|
||||
|
||||
/* ── Rule 3: the commodity lexicon ───────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Loose, unbranded groceries, in English, Tamil and Hindi.
|
||||
*
|
||||
* Separate from the registry because these are what a shop writes on a board
|
||||
* rather than what a brand prints on a pack — a row reading just "kothamalli"
|
||||
* or "अंडा" has no brand, no description and nothing for the registry to match.
|
||||
*/
|
||||
const COMMODITY_LEXICON: Record<string, string> = {
|
||||
// Pulses
|
||||
paruppu: 'Pulses, Grains & Spices',
|
||||
kadalai: 'Pulses, Grains & Spices',
|
||||
// Grains and flour
|
||||
arisi: 'Atta & Staples',
|
||||
chawal: 'Atta & Staples',
|
||||
gehun: 'Atta & Staples',
|
||||
// Greens and herbs
|
||||
keerai: 'Fresh Herbs & Greens',
|
||||
kothamalli: 'Fresh Herbs & Greens',
|
||||
kariveppilai: 'Fresh Herbs & Greens',
|
||||
dhaniya: 'Fresh Herbs & Greens',
|
||||
// Produce
|
||||
kaai: 'Fruits & Vegetables',
|
||||
pazham: 'Fruits & Vegetables',
|
||||
sabzi: 'Fruits & Vegetables',
|
||||
// Flowers
|
||||
poo: 'Flowers',
|
||||
malli: 'Flowers',
|
||||
// Fish
|
||||
meen: 'Fish & Seafood',
|
||||
machli: 'Fish & Seafood',
|
||||
// Eggs
|
||||
muttai: 'Eggs',
|
||||
anda: 'Eggs',
|
||||
// Spice
|
||||
milagai: 'Spices & Masalas',
|
||||
manjal: 'Spices & Masalas',
|
||||
};
|
||||
|
||||
/**
|
||||
* A pack size that contradicts the lexicon's answer.
|
||||
*
|
||||
* Their rule, and the reason for it: loose commodities are weighed, so a row
|
||||
* that names one but carries a sealed retail pack is a branded product wearing
|
||||
* a commodity noun — "Meen Kulambu Masala 100g" is a masala, not fish.
|
||||
*/
|
||||
function packContradicts(category: string, packSize: string): boolean {
|
||||
const loose = ['Fruits & Vegetables', 'Fresh Herbs & Greens', 'Flowers', 'Fish & Seafood', 'Eggs'];
|
||||
if (!loose.includes(category)) return false;
|
||||
return /\b\d+\s?(g|gm|ml)\b/i.test(packSize);
|
||||
}
|
||||
|
||||
/** The lexicon's nouns, exposed so a test can assert the two layers stay apart. */
|
||||
export const LEXICON_WORDS = Object.keys(COMMODITY_LEXICON);
|
||||
|
||||
export function commodityCategory(title: string, packSize = ''): string | null {
|
||||
const words = title.toLowerCase().split(/[^a-zऀ-ॿ-]+/).filter(Boolean);
|
||||
for (const word of words) {
|
||||
const category = COMMODITY_LEXICON[word];
|
||||
if (category && !packContradicts(category, packSize)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── The ladder ──────────────────────────────────────────────────────────── */
|
||||
|
||||
export type CategoryRule = 'sheet' | 'registry' | 'lexicon' | 'unknown';
|
||||
|
||||
export interface CategoryVerdict {
|
||||
category: string;
|
||||
/** Which rule answered. Shown in the import preview so the answer is auditable. */
|
||||
rule: CategoryRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four rules, in order. First hit wins; the rest never run.
|
||||
*
|
||||
* `rule` comes back with the answer because an operator reviewing an import
|
||||
* needs to know whether a category was their own column or something this
|
||||
* inferred — the first is theirs to trust, the second is theirs to check.
|
||||
*/
|
||||
export function resolveCategory(input: {
|
||||
title: string;
|
||||
description?: string;
|
||||
/** The sheet's category cell, if it had one. */
|
||||
sheetValue?: string;
|
||||
packSize?: string;
|
||||
}): CategoryVerdict {
|
||||
const sheet = (input.sheetValue ?? '').trim();
|
||||
if (sheet && !isCategoryCode(sheet)) return { category: sheet, rule: 'sheet' };
|
||||
|
||||
const registry = detectCategoryFromText(input.title, input.description ?? '');
|
||||
if (registry) return { category: registry, rule: 'registry' };
|
||||
|
||||
const lexicon = commodityCategory(input.title, input.packSize ?? '');
|
||||
if (lexicon) return { category: lexicon, rule: 'lexicon' };
|
||||
|
||||
return { category: UNKNOWN_CATEGORY, rule: 'unknown' };
|
||||
}
|
||||
|
||||
/** The neutral noun for a category, for repairing a wrong description. */
|
||||
export function genericTermFor(category: string): string | null {
|
||||
return CATEGORY_REGISTRY.find((entry) => entry.category === category)?.genericTerm ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user