console category
This commit is contained in:
167
scripts/refileCategories.ts
Normal file
167
scripts/refileCategories.ts
Normal 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();
|
||||||
@@ -152,6 +152,15 @@ export const productsApi = {
|
|||||||
resolveCategories: (tenantid: number, names: string[]) =>
|
resolveCategories: (tenantid: number, names: string[]) =>
|
||||||
api.post<Record<string, number>>(`${WEB}/products/resolvecategories`, { tenantid, names }),
|
api.post<Record<string, number>>(`${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. */
|
/** Unlinks from the store. The product row and its order history survive. */
|
||||||
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
|
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
|
||||||
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
|
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
|
||||||
|
|||||||
@@ -211,8 +211,23 @@ export function CatalogueBrowser({
|
|||||||
const selection = useSelection(selectableIds);
|
const selection = useSelection(selectableIds);
|
||||||
|
|
||||||
const importMany = useMutation({
|
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) => {
|
onSuccess: async (_result, products) => {
|
||||||
// Ticked locally as well as refetched: the imported list is a separate
|
// 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
|
// 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
|
* 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.
|
* 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<Map<string, number>> {
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
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<string, number> = {};
|
||||||
|
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<number> {
|
||||||
|
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 {
|
return {
|
||||||
tenantid: tenantid as number,
|
tenantid: tenantid as number,
|
||||||
locationid: locationid 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.
|
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
|
A wrongly filed product is at least findable and fixable; one under
|
||||||
category 0 is returned by no query the app makes. */
|
category 0 is returned by no query the app makes. */
|
||||||
categoryid: Number(chosenCategory),
|
categoryid,
|
||||||
subcategoryid: 0,
|
subcategoryid: 0,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
stocktype: 'in',
|
stocktype: 'in',
|
||||||
@@ -299,7 +378,7 @@ export function CatalogueBrowser({
|
|||||||
const key = catalogueKey(product);
|
const key = catalogueKey(product);
|
||||||
setBusy(key);
|
setBusy(key);
|
||||||
try {
|
try {
|
||||||
await importOne.mutateAsync(importRowFor(product));
|
await importOne.mutateAsync(importRowFor(product, await categoryIdFor(product)));
|
||||||
setJustImported((set) => new Set(set).add(key));
|
setJustImported((set) => new Set(set).add(key));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
|
|||||||
@@ -276,8 +276,12 @@ export function CatalogueDetailDrawer({
|
|||||||
showing shoppers an empty shop, while the console listed
|
showing shoppers an empty shop, while the console listed
|
||||||
their stock as normal. A wrongly filed product is at least
|
their stock as normal. A wrongly filed product is at least
|
||||||
findable and fixable; an unfiled one was neither. */
|
findable and fixable; an unfiled one was neither. */
|
||||||
placeholder="Choose a category"
|
placeholder={product.category ? `Catalogue: ${product.category}` : 'Choose a category'}
|
||||||
description="Your own category, not the catalogue's. A product with no category cannot appear in the customer app at all."
|
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}
|
) : null}
|
||||||
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user