console category

This commit is contained in:
2026-09-07 17:50:57 +05:30
parent 954dfb8c00
commit 56f183f6f6
4 changed files with 266 additions and 7 deletions

167
scripts/refileCategories.ts Normal file
View File

@@ -0,0 +1,167 @@
/**
* Re-files a tenant's products under the catalogue's own categories.
*
* 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.
*
* npx tsx scripts/refileCategories.ts 1147 # dry run, writes nothing
* npx tsx scripts/refileCategories.ts 1147 --apply # writes
*
* ── How a product is matched to its catalogue row ───────────────────────────
*
* On `brand` + `productsku`, never on `catalogueid`. The catalogue renumbers
* its ids on every re-scrape — 11 of 19 links were already broken when that was
* last measured — so a product's stored `catalogueid` points at whatever
* happens to sit at that number today, which may be a different product.
*
* ── What it does when there is no catalogue row ─────────────────────────────
*
* Falls back to the same deterministic ladder the import uses, so a product
* typed in by hand is filed too rather than left behind. The report says which
* source decided each one, because "the catalogue says so" and "we guessed from
* the name" are different levels of confidence and an operator reviewing 300
* rows deserves to know which is which.
*/
import { catalogueApi } from '../src/api/catalogue';
import { productsApi } from '../src/api/products';
import { resolveCategory, UNKNOWN_CATEGORY } from '../src/features/store-admin/productCategory';
import type { CatalogueProduct, Product } from '../src/api/types';
const tenantid = Number(process.argv[2]);
const isApply = process.argv.includes('--apply');
if (!tenantid) {
console.error('Usage: npx tsx scripts/refileCategories.ts <tenantid> [--apply]');
process.exit(1);
}
type Source = 'catalogue' | 'ladder';
interface Plan {
product: Product;
from: number;
toName: string;
source: Source;
}
/** Every catalogue row for one brand, keyed by SKU. One request per brand. */
async function catalogueByBrand(brand: string): Promise<Map<string, CatalogueProduct>> {
const out = new Map<string, CatalogueProduct>();
for (let page = 0; page < 20; page += 1) {
const rows = await catalogueApi.products({ brand, pageno: page, pagesize: 500 });
for (const row of rows) {
if (row.product_sku) out.set(row.product_sku.trim().toLowerCase(), row);
}
if (rows.length < 500) break;
}
return out;
}
async function main() {
const products = await productsApi.locationProducts({ tenantid, locationid: 0, pagesize: 2000 });
console.log(`${products.length} products for tenant ${tenantid}\n`);
// One catalogue read per distinct brand, not one per product.
const brands = [...new Set(products.map((p) => (p.productbrand ?? '').trim()).filter(Boolean))];
const catalogue = new Map<string, Map<string, CatalogueProduct>>();
for (const brand of brands) {
try {
catalogue.set(brand.toLowerCase(), await catalogueByBrand(brand));
} catch {
catalogue.set(brand.toLowerCase(), new Map());
}
}
const plans: Plan[] = [];
for (const product of products) {
const brand = (product.productbrand ?? '').trim().toLowerCase();
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;
plans.push({
product,
from: product.categoryid ?? 0,
toName,
source: fromCatalogue ? '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]));
const byCategory: Record<string, { count: number; source: Source }> = {};
let unchanged = 0;
const writes: Plan[] = [];
for (const plan of plans) {
const to = ids[plan.toName.toLowerCase()];
if (to !== undefined && 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 };
}
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}`));
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}".`);
}
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})`,
);
});
if (!isApply) {
console.log('\nDry run. Nothing was written. Re-run with --apply to write.');
return;
}
console.log('\nWriting…');
/*
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
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.
*/
const updates = writes
.map((plan) => ({
productid: plan.product.productid,
categoryid: ids[plan.toName.toLowerCase()] ?? 0,
}))
.filter((row) => row.categoryid > 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.`);
}
void main();