diff --git a/scripts/refileCategories.ts b/scripts/refileCategories.ts new file mode 100644 index 0000000..7cc3163 --- /dev/null +++ b/scripts/refileCategories.ts @@ -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 [--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> { + const out = new Map(); + 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>(); + 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 = {}; + 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(); diff --git a/src/api/products.ts b/src/api/products.ts index b237469..e6edb1e 100644 --- a/src/api/products.ts +++ b/src/api/products.ts @@ -152,6 +152,15 @@ export const productsApi = { resolveCategories: (tenantid: number, names: string[]) => api.post>(`${WEB}/products/resolvecategories`, { tenantid, names }), + /** + * Re-files products into different categories, in bulk. + * + * Scoped by tenant on the server as well as here — a productid is global, so + * a wrong id in the list would otherwise move another merchant's product. + */ + recategorise: (tenantid: number, updates: { productid: number; categoryid: number }[]) => + api.put<{ moved: number }>(`${WEB}/products/recategorise`, { tenantid, updates }), + /** Unlinks from the store. The product row and its order history survive. */ removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) => api.del(`${WEB}/products/deleteproductlocation`, body), diff --git a/src/features/catalogue/CatalogueBrowser.tsx b/src/features/catalogue/CatalogueBrowser.tsx index 3eb4f8c..b4e7f88 100644 --- a/src/features/catalogue/CatalogueBrowser.tsx +++ b/src/features/catalogue/CatalogueBrowser.tsx @@ -211,8 +211,23 @@ export function CatalogueBrowser({ const selection = useSelection(selectableIds); const importMany = useMutation({ - mutationFn: (products: CatalogueProduct[]) => - productsApi.importFromCatalogue(products.map(importRowFor)), + /* + Every distinct category in the batch resolved in ONE call, then each row + built with its own id. + + Not `products.map(importRowFor)`: `map` hands the callback the array + INDEX as its second argument, which is a number, so passing the row + builder directly type-checks perfectly and files the first product under + category 0, the second under 1, and so on. It was written that way for a + one-argument builder and stayed valid the moment the second argument + arrived. + */ + mutationFn: async (products: CatalogueProduct[]) => { + const ids = await categoryIdsFor(products); + return productsApi.importFromCatalogue( + products.map((product) => importRowFor(product, ids.get(catalogueKey(product)) ?? 0)), + ); + }, onSuccess: async (_result, products) => { // Ticked locally as well as refetched: the imported list is a separate // query and the grid would otherwise show them as un-imported until it @@ -268,7 +283,71 @@ export function CatalogueBrowser({ * row. Two copies of this object is how a bulk import quietly writes a * different category, or a price where the single one writes none. */ - function importRowFor(product: CatalogueProduct): ImportCatalogueProductRequest { + /** + * The category id an import should use for one catalogue product. + * + * The catalogue already knows what this is — "Spices & Masalas" sits on the + * row — and that answer comes from the same deterministic ladder the + * pipeline runs. Discarding it in favour of whatever single category the + * tenant happened to have is how an Aachi masala arrived filed under + * "Category 2". + * + * An EXPLICIT choice still wins, which is the ladder's own first rule: if the + * operator has touched the picker, that is their answer and it is kept. Until + * they do, `importInto` is null and the catalogue's own category is used. + */ + /** + * The same decision for a whole batch, in one round trip. + * + * A request per product would open one connection per row from a shop's + * browser — the mistake the catalogue reconciliation already documents. + */ + async function categoryIdsFor(products: CatalogueProduct[]): Promise> { + const out = new Map(); + const fallback = Number(chosenCategory); + + if (importInto !== null && importInto !== '') { + for (const product of products) out.set(catalogueKey(product), Number(importInto)); + return out; + } + + const names = [...new Set(products.map((p) => (p.category ?? '').trim()).filter(Boolean))]; + let resolved: Record = {}; + if (names.length > 0) { + try { + resolved = (await productsApi.resolveCategories(tenantid as number, names)) ?? {}; + } catch { + resolved = {}; + } + } + + for (const product of products) { + const name = (product.category ?? '').trim().toLowerCase(); + out.set(catalogueKey(product), resolved[name] ?? fallback); + } + return out; + } + + async function categoryIdFor(product: CatalogueProduct): Promise { + if (importInto !== null && importInto !== '') return Number(importInto); + + const name = (product.category ?? '').trim(); + if (!name) return Number(chosenCategory); + + try { + const resolved = await productsApi.resolveCategories(tenantid as number, [name]); + return resolved?.[name.toLowerCase()] ?? Number(chosenCategory); + } catch { + // The tenant's own list still files it somewhere findable. Better a + // wrong shelf than categoryid 0, which no query the app makes returns. + return Number(chosenCategory); + } + } + + function importRowFor( + product: CatalogueProduct, + categoryid: number, + ): ImportCatalogueProductRequest { return { tenantid: tenantid as number, locationid: locationid as number, @@ -280,7 +359,7 @@ export function CatalogueBrowser({ like any other product and only the customer app knows it is gone. A wrongly filed product is at least findable and fixable; one under category 0 is returned by no query the app makes. */ - categoryid: Number(chosenCategory), + categoryid, subcategoryid: 0, quantity: 0, stocktype: 'in', @@ -299,7 +378,7 @@ export function CatalogueBrowser({ const key = catalogueKey(product); setBusy(key); try { - await importOne.mutateAsync(importRowFor(product)); + await importOne.mutateAsync(importRowFor(product, await categoryIdFor(product))); setJustImported((set) => new Set(set).add(key)); } finally { setBusy(null); diff --git a/src/features/catalogue/CatalogueDetailDrawer.tsx b/src/features/catalogue/CatalogueDetailDrawer.tsx index 79f7ab7..4e143d9 100644 --- a/src/features/catalogue/CatalogueDetailDrawer.tsx +++ b/src/features/catalogue/CatalogueDetailDrawer.tsx @@ -276,8 +276,12 @@ export function CatalogueDetailDrawer({ showing shoppers an empty shop, while the console listed their stock as normal. A wrongly filed product is at least findable and fixable; an unfiled one was neither. */ - placeholder="Choose a category" - description="Your own category, not the catalogue's. A product with no category cannot appear in the customer app at all." + placeholder={product.category ? `Catalogue: ${product.category}` : 'Choose a category'} + description={ + product.category + ? `Left alone, this is filed under ${product.category} — the catalogue's own category, created for your shop if you do not have it yet. Choose one here to override.` + : 'The catalogue does not categorise this one, so pick where it belongs. A product with no category cannot appear in the customer app at all.' + } /> ) : null}