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

@@ -1,10 +1,15 @@
/**
* Re-files a tenant's products under the catalogue's own categories.
* Puts a tenant's existing products into the aisles the customer app displays.
*
* Everything imported before the category work landed carries whatever single
* category the tenant had — "Category 2" for most shops — while the catalogue
* has known all along that an Aachi masala is Spices & Masalas. This reads that
* answer back and writes it.
* Everything imported before this carries `subcategoryid: 0`, which the app
* renders as one heading called "Uncategorized" holding the entire shop —
* measured on live tenant 1135/1166 — while the catalogue has known all along
* that an Aachi masala is Spices & Masalas. This reads that answer back, folds
* it into one of the app's ten aisles (`appAisle.ts`) and writes it.
*
* `categoryid` is deliberately NOT changed. `getproductsbysubcategory` filters
* on it with the 2 the app sends, so a per-product categoryid does not label a
* product, it removes it from the app entirely.
*
* npx tsx scripts/refileCategories.ts 1147 # dry run, writes nothing
* npx tsx scripts/refileCategories.ts 1147 --apply # writes
@@ -27,7 +32,12 @@
import { catalogueApi } from '../src/api/catalogue';
import { productsApi } from '../src/api/products';
import { resolveCategory, UNKNOWN_CATEGORY } from '../src/features/store-admin/productCategory';
import {
categoryForCatalogueProduct,
UNKNOWN_CATEGORY,
} from '../src/features/store-admin/productCategory';
import { aisleForCategory, aisleIdsFrom } from '../src/features/store-admin/appAisle';
import { APP_BROWSE_CATEGORY } from '../src/features/catalogue/tenantCategories';
import type { CatalogueProduct, Product } from '../src/api/types';
const tenantid = Number(process.argv[2]);
@@ -42,8 +52,12 @@ type Source = 'catalogue' | 'ladder';
interface Plan {
product: Product;
/** The subcategory the product sits in today — 0 for everything, so far. */
from: number;
toName: string;
/** One of the catalogue's 31, for the report. */
category: string;
/** One of the app's ten aisles, or null when the category folds to none. */
aisle: string | null;
source: Source;
}
@@ -81,58 +95,82 @@ async function main() {
const sku = (product.productsku ?? '').trim().toLowerCase();
const row = brand && sku ? catalogue.get(brand)?.get(sku) : undefined;
const fromCatalogue = (row?.category ?? '').trim();
const toName = fromCatalogue
? fromCatalogue
: resolveCategory({
title: product.productname ?? '',
description: product.productdesc ?? '',
packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '),
}).category;
/*
The catalogue's answer only when it is one of the 31.
It carries names the platform does not have — "Food - Mixes", "Pickles &
Chutneys", "Dairy - Desserts" on about a third of the rows sampled — and
taking those verbatim would file a shop's products under aisles the app
cannot browse and no other shop shares. The same gate the import uses.
*/
const verdict = categoryForCatalogueProduct({
catalogueCategory: row?.category ?? '',
title: product.productname ?? '',
description: product.productdesc ?? '',
packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '),
});
plans.push({
product,
from: product.categoryid ?? 0,
toName,
source: fromCatalogue ? 'catalogue' : 'ladder',
from: product.subcategoryid ?? 0,
category: verdict.category,
aisle: aisleForCategory(verdict.category),
source: verdict.rule === 'catalogue' ? 'catalogue' : 'ladder',
});
}
// Resolve every distinct name once, creating the aisles that do not exist.
const names = [...new Set(plans.map((p) => p.toName))];
const ids = isApply
? ((await productsApi.resolveCategories(tenantid, names)) ?? {})
: Object.fromEntries(names.map((n) => [n.toLowerCase(), -1]));
// The aisle ids, by name, from the platform's own list — see `appAisle.ts`
// for why they are matched on the name and not remembered as numbers.
const aisleIds = aisleIdsFrom(
await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY).catch(() => undefined),
);
const byCategory: Record<string, { count: number; source: Source }> = {};
const byAisle: Record<string, number> = {};
let unchanged = 0;
const writes: Plan[] = [];
const orphans: Plan[] = [];
for (const plan of plans) {
const to = ids[plan.toName.toLowerCase()];
if (to !== undefined && to === plan.from) {
if (!plan.aisle) {
orphans.push(plan);
continue;
}
const to = aisleIds.get(plan.aisle.toLowerCase()) ?? 0;
if (to === plan.from) {
unchanged += 1;
continue;
}
writes.push(plan);
const key = `${plan.toName} (${plan.source})`;
byCategory[key] = { count: (byCategory[key]?.count ?? 0) + 1, source: plan.source };
const key = `${plan.aisle} ← ${plan.category} (${plan.source})`;
byAisle[key] = (byAisle[key] ?? 0) + 1;
}
console.log(`${writes.length} would be re-filed, ${unchanged} already correct\n`);
Object.entries(byCategory)
.sort((a, b) => b[1].count - a[1].count)
.forEach(([name, { count }]) => console.log(` ${String(count).padStart(4)} ${name}`));
console.log(`${writes.length} would be re-filed, ${unchanged} already in the right aisle
`);
Object.entries(byAisle)
.sort((x, y) => y[1] - x[1])
.forEach(([name, count]) => console.log(` ${String(count).padStart(4)} ${name}`));
const unknown = writes.filter((p) => p.toName === UNKNOWN_CATEGORY).length;
if (unknown > 0) {
console.log(`\n${unknown} could not be identified and would go to "${UNKNOWN_CATEGORY}".`);
if (orphans.length > 0) {
console.log(
`
${orphans.length} have no aisle and would stay under the app's "Uncategorized":`,
);
orphans
.slice(0, 10)
.forEach((p) =>
console.log(
` ${(p.product.productname ?? '').slice(0, 44).padEnd(46)} ${p.category}`,
),
);
const unknown = orphans.filter((p) => p.category === UNKNOWN_CATEGORY).length;
if (unknown > 0) console.log(` (${unknown} of them could not be identified at all)`);
}
console.log('\nA sample of what changes:');
writes.slice(0, 12).forEach((p) => {
console.log(
` ${(p.product.productname ?? '').slice(0, 40).padEnd(42)} ${p.from} → ${p.toName} (${p.source})`,
` ${(p.product.productname ?? '').slice(0, 40).padEnd(42)} ${p.from} → ${p.aisle} (${p.source})`,
);
});
@@ -145,8 +183,8 @@ async function main() {
/*
One call for the whole tenant, not one request per product.
`recategorise` writes a single column and is scoped by tenantid on the server,
so it cannot reach another merchant's rows and cannot overwrite a price the
`recategorise` writes the category and subcategory columns only, and is scoped
by tenantid on the server, so it cannot reach another merchant's rows and cannot overwrite a price the
way a whole-row update would. `PUT /products/update` was the obvious candidate
and is the wrong one: it updates `productlocations.status` and never touches
the products table at all.
@@ -154,14 +192,16 @@ async function main() {
const updates = writes
.map((plan) => ({
productid: plan.product.productid,
categoryid: ids[plan.toName.toLowerCase()] ?? 0,
// Unchanged, and that is the point: it is the app's filter, not a label.
categoryid: APP_BROWSE_CATEGORY,
subcategoryid: aisleIds.get((plan.aisle ?? '').toLowerCase()) ?? 0,
}))
.filter((row) => row.categoryid > 0);
.filter((row) => row.subcategoryid > 0);
const skipped = writes.length - updates.length;
const result = await productsApi.recategorise(tenantid, updates);
console.log(`re-filed ${result?.moved ?? 0} of ${updates.length} sent`);
if (skipped > 0) console.log(`${skipped} skipped — no id could be resolved for their category.`);
if (skipped > 0) console.log(`${skipped} skipped — no id could be resolved for their aisle.`);
}
void main();