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 * Everything imported before this carries `subcategoryid: 0`, which the app
* category the tenant had — "Category 2" for most shops — while the catalogue * renders as one heading called "Uncategorized" holding the entire shop —
* has known all along that an Aachi masala is Spices & Masalas. This reads that * measured on live tenant 1135/1166 — while the catalogue has known all along
* answer back and writes it. * 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 # dry run, writes nothing
* npx tsx scripts/refileCategories.ts 1147 --apply # writes * npx tsx scripts/refileCategories.ts 1147 --apply # writes
@@ -27,7 +32,12 @@
import { catalogueApi } from '../src/api/catalogue'; import { catalogueApi } from '../src/api/catalogue';
import { productsApi } from '../src/api/products'; 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'; import type { CatalogueProduct, Product } from '../src/api/types';
const tenantid = Number(process.argv[2]); const tenantid = Number(process.argv[2]);
@@ -42,8 +52,12 @@ type Source = 'catalogue' | 'ladder';
interface Plan { interface Plan {
product: Product; product: Product;
/** The subcategory the product sits in today — 0 for everything, so far. */
from: number; 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; source: Source;
} }
@@ -81,58 +95,82 @@ async function main() {
const sku = (product.productsku ?? '').trim().toLowerCase(); const sku = (product.productsku ?? '').trim().toLowerCase();
const row = brand && sku ? catalogue.get(brand)?.get(sku) : undefined; const row = brand && sku ? catalogue.get(brand)?.get(sku) : undefined;
const fromCatalogue = (row?.category ?? '').trim(); /*
const toName = fromCatalogue The catalogue's answer only when it is one of the 31.
? fromCatalogue
: resolveCategory({ 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 ?? '', title: product.productname ?? '',
description: product.productdesc ?? '', description: product.productdesc ?? '',
packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '), packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '),
}).category; });
plans.push({ plans.push({
product, product,
from: product.categoryid ?? 0, from: product.subcategoryid ?? 0,
toName, category: verdict.category,
source: fromCatalogue ? 'catalogue' : 'ladder', aisle: aisleForCategory(verdict.category),
source: verdict.rule === 'catalogue' ? 'catalogue' : 'ladder',
}); });
} }
// Resolve every distinct name once, creating the aisles that do not exist. // The aisle ids, by name, from the platform's own list — see `appAisle.ts`
const names = [...new Set(plans.map((p) => p.toName))]; // for why they are matched on the name and not remembered as numbers.
const ids = isApply const aisleIds = aisleIdsFrom(
? ((await productsApi.resolveCategories(tenantid, names)) ?? {}) await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY).catch(() => undefined),
: Object.fromEntries(names.map((n) => [n.toLowerCase(), -1])); );
const byCategory: Record<string, { count: number; source: Source }> = {}; const byAisle: Record<string, number> = {};
let unchanged = 0; let unchanged = 0;
const writes: Plan[] = []; const writes: Plan[] = [];
const orphans: Plan[] = [];
for (const plan of plans) { for (const plan of plans) {
const to = ids[plan.toName.toLowerCase()]; if (!plan.aisle) {
if (to !== undefined && to === plan.from) { orphans.push(plan);
continue;
}
const to = aisleIds.get(plan.aisle.toLowerCase()) ?? 0;
if (to === plan.from) {
unchanged += 1; unchanged += 1;
continue; continue;
} }
writes.push(plan); writes.push(plan);
const key = `${plan.toName} (${plan.source})`; const key = `${plan.aisle} ← ${plan.category} (${plan.source})`;
byCategory[key] = { count: (byCategory[key]?.count ?? 0) + 1, source: plan.source }; byAisle[key] = (byAisle[key] ?? 0) + 1;
} }
console.log(`${writes.length} would be re-filed, ${unchanged} already correct\n`); console.log(`${writes.length} would be re-filed, ${unchanged} already in the right aisle
Object.entries(byCategory) `);
.sort((a, b) => b[1].count - a[1].count) Object.entries(byAisle)
.forEach(([name, { count }]) => console.log(` ${String(count).padStart(4)} ${name}`)); .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 (orphans.length > 0) {
if (unknown > 0) { console.log(
console.log(`\n${unknown} could not be identified and would go to "${UNKNOWN_CATEGORY}".`); `
${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:'); console.log('\nA sample of what changes:');
writes.slice(0, 12).forEach((p) => { writes.slice(0, 12).forEach((p) => {
console.log( 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. One call for the whole tenant, not one request per product.
`recategorise` writes a single column and is scoped by tenantid on the server, `recategorise` writes the category and subcategory columns only, and is scoped
so it cannot reach another merchant's rows and cannot overwrite a price the 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 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 and is the wrong one: it updates `productlocations.status` and never touches
the products table at all. the products table at all.
@@ -154,14 +192,16 @@ async function main() {
const updates = writes const updates = writes
.map((plan) => ({ .map((plan) => ({
productid: plan.product.productid, 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 skipped = writes.length - updates.length;
const result = await productsApi.recategorise(tenantid, updates); const result = await productsApi.recategorise(tenantid, updates);
console.log(`re-filed ${result?.moved ?? 0} of ${updates.length} sent`); 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(); void main();

View File

@@ -29,6 +29,8 @@ import type {
ProductStockRequest, ProductStockRequest,
ProductSubCategory, ProductSubCategory,
} from './types'; } from './types';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
export interface LocationProductQuery { export interface LocationProductQuery {
tenantid: number; tenantid: number;
@@ -153,13 +155,20 @@ export const productsApi = {
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. * Re-files products into different aisles, in bulk.
*
* `categoryid` should be 2 on every row — the customer app FILTERS on it and
* anything else removes the product from its browse. `subcategoryid` is the
* one that decides the heading a shopper reads; 0 leaves whatever the product
* already has, so a caller that does not know the aisle cannot erase one.
* *
* Scoped by tenant on the server as well as here — a productid is global, so * 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. * a wrong id in the list would otherwise move another merchant's product.
*/ */
recategorise: (tenantid: number, updates: { productid: number; categoryid: number }[]) => recategorise: (
api.put<{ moved: number }>(`${WEB}/products/recategorise`, { tenantid, updates }), tenantid: number,
updates: { productid: number; categoryid: number; subcategoryid?: 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 }) =>
@@ -237,30 +246,26 @@ export async function importSheetProducts(
const createdSkus: string[] = []; const createdSkus: string[] = [];
/* /*
Categories first, in one call, before a single product is created. The aisles first, in one call, before a single product is created.
Every row carries a category NAME worked out by the ladder in Every row carries a category NAME worked out by the ladder in
`productCategory.ts`, and the customer app browses by categoryid and rejects `productCategory.ts`. What the customer app groups by is not that category
0 — so a product created before its aisle exists is a product no shopper can but `products.subcategoryid` — one of ten platform rows under category 2 —
find. Resolving up front also means one round trip for the whole sheet rather so the name is folded into an aisle and the aisle looked up by name here. See
than one per row. `appAisle.ts` for the endpoint that is measured against.
A failure here is not fatal. The rows still carry whatever categoryid the A failure here is not fatal: the lookup falls back to the ids last read from
operator picked for the upload, which is exactly the behaviour this had the platform, and a row that still cannot be placed is created with
before, so a category service that is down costs the new filing and not the subcategoryid 0, which the app lists under "Uncategorized".
import.
*/ */
const wanted = [...new Set(rows.map((row) => row.category ?? '').filter(Boolean))]; let aisleIds: ReadonlyMap<string, number>;
let resolved: Record<string, number> = {};
if (wanted.length > 0) {
try { try {
resolved = (await productsApi.resolveCategories(tenantid, wanted)) ?? {}; aisleIds = aisleIdsFrom(await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY));
} catch { } catch {
resolved = {}; aisleIds = aisleIdsFrom(undefined);
} }
} const subcategoryIdFor = (row: SheetProductRow): number =>
const categoryIdFor = (row: SheetProductRow): number => aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0;
resolved[(row.category ?? '').trim().toLowerCase()] ?? row.categoryid;
for (const [index, row] of rows.entries()) { for (const [index, row] of rows.entries()) {
try { try {
@@ -268,8 +273,10 @@ export async function importSheetProducts(
tenantid, tenantid,
productname: row.productname, productname: row.productname,
productsku: row.productsku, productsku: row.productsku,
categoryid: categoryIdFor(row), // ALWAYS 2 — the app filters on it and would drop anything else. The
subcategoryid: row.subcategoryid, // aisle a shopper reads is the subcategory.
categoryid: APP_BROWSE_CATEGORY,
subcategoryid: subcategoryIdFor(row),
retailprice: row.retailprice, retailprice: row.retailprice,
productcost: row.productcost, productcost: row.productcost,
taxpercent: row.taxpercent, taxpercent: row.taxpercent,

View File

@@ -13,6 +13,8 @@ import { VStack } from '@astryxdesign/core/VStack';
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react'; import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue'; import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory'; import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
import { APP_BROWSE_CATEGORY } from './tenantCategories';
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
import { useSelection } from '@/components/useSelection'; import { useSelection } from '@/components/useSelection';
import { productsApi } from '@/api/products'; import { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types'; import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
@@ -34,8 +36,6 @@ export interface CatalogueBrowserProps {
tenantid: number | undefined; tenantid: number | undefined;
/** The outlet the import is written against. Required by the backend. */ /** The outlet the import is written against. Required by the backend. */
locationid: number | undefined; locationid: number | undefined;
/** The tenant's own categories, for the one field the catalogue cannot supply. */
categoryOptions: { value: string; label: string }[];
/** Wording on the card and drawer buttons. */ /** Wording on the card and drawer buttons. */
actionLabel: string; actionLabel: string;
/** /**
@@ -56,15 +56,6 @@ export interface CatalogueBrowserProps {
* rather than one at a time into somebody else's shop. * rather than one at a time into somebody else's shop.
*/ */
isReadOnly?: boolean; isReadOnly?: boolean;
/**
* Lift the search row onto the page's tab row.
*
* Only true where a tab row actually exists — the Store Admin's Inventory.
* On a page without one the offset drags the row up over the page header's
* own actions and swallows their clicks, which is exactly what it did to the
* platform catalogue's mode toggle.
*/
alignWithTabs?: boolean;
/** Why importing is unavailable, if it is. */ /** Why importing is unavailable, if it is. */
blockedReason?: string; blockedReason?: string;
} }
@@ -89,35 +80,17 @@ export interface CatalogueBrowserProps {
export function CatalogueBrowser({ export function CatalogueBrowser({
tenantid, tenantid,
locationid, locationid,
categoryOptions,
actionLabel, actionLabel,
onImport, onImport,
scope, scope,
blockedReason, blockedReason,
isReadOnly, isReadOnly,
alignWithTabs,
}: CatalogueBrowserProps) { }: CatalogueBrowserProps) {
const client = useQueryClient(); const client = useQueryClient();
const [brand, setBrand] = useState(''); const [brand, setBrand] = useState('');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [debounced, setDebounced] = useState(''); const [debounced, setDebounced] = useState('');
/**
* `null` means "not chosen yet", which is NOT the same as "no category".
*
* This was `useState('')`, and an empty string went on to `Number('') || 0`
* and imported the product under `categoryid` 0. That reads like a harmless
* default and is not: the customer app queries products by category, and a
* product filed under 0 is returned for NO category value — verified against
* tenant 1147, where `getproductsbysubcategory` answered with the other five
* products at `categoryid=2` and with nothing at 0, 1 or 1001. Two real
* products sat invisible in the app while this console listed them happily,
* because `getlocationproducts` does not filter on category.
*
* Holding "unchosen" apart from "chosen nothing" lets the default below be
* the tenant's own category rather than the value that hides the product.
*/
const [importInto, setImportInto] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set()); const [justImported, setJustImported] = useState<Set<string>>(new Set());
const [open, setOpen] = useState<CatalogueProduct | null>(null); const [open, setOpen] = useState<CatalogueProduct | null>(null);
@@ -224,9 +197,11 @@ export function CatalogueBrowser({
arrived. arrived.
*/ */
mutationFn: async (products: CatalogueProduct[]) => { mutationFn: async (products: CatalogueProduct[]) => {
const ids = await categoryIdsFor(products); const ids = await aisleIds();
return productsApi.importFromCatalogue( return productsApi.importFromCatalogue(
products.map((product) => importRowFor(product, ids.get(catalogueKey(product)) ?? 0)), products.map((product) =>
importRowFor(product, aisleIdForCategory(categoryNameFor(product), ids)),
),
); );
}, },
onSuccess: async (_result, products) => { onSuccess: async (_result, products) => {
@@ -256,43 +231,8 @@ export function CatalogueBrowser({
}, },
}); });
/**
* The LAST-RESORT category: the tenant's first real one.
*
* Only reached when a product carries no catalogue category of its own and
* nothing has been picked. `categoryOptions` arrives asynchronously, so this
* is derived at render rather than pushed into state by an effect — state
* seeded from an empty list would keep the old default after the categories
* loaded, and the picker would then show one thing while the request sent
* another.
*/
const defaultCategory = categoryOptions.find((option) => option.value !== '')?.value ?? '';
const chosenCategory = importInto ?? defaultCategory;
/**
* What the picker DISPLAYS, which is not the same as the fallback above.
*
* Empty until the operator picks, so the control shows its placeholder — and
* the placeholder names the catalogue's own category, which is what an
* untouched import actually uses.
*
* Showing `chosenCategory` here was a straight contradiction on screen: the
* description read "filed under Food - Mixes" while the select beside it
* showed "Category 2" as the chosen value, because the fallback had been
* pressed into service as a display value. The two are different questions —
* "what will happen" and "what have you chosen" — and only the second belongs
* in the control.
*/
const pickerValue = importInto ?? '';
/**
* True when an import would produce a product the customer app cannot show.
*
* The tenant has no category to file into, so the write would go in under 0.
* Better to say so and refuse than to write a row that looks imported in this
* console and does not exist as far as a shopper is concerned.
*/
const hasNoCategory = !isReadOnly && !onImport && chosenCategory === '';
/** /**
* One import row. * One import row.
@@ -337,65 +277,40 @@ export function CatalogueBrowser({
}).category; }).category;
} }
async function categoryIdsFor(products: CatalogueProduct[]): Promise<Map<string, number>> { /**
const out = new Map<string, number>(); * The aisle ids the app groups by, read from the platform's own list.
const fallback = Number(chosenCategory); *
* One call for a whole batch. It is `productsubcategories` for category 2 —
if (importInto !== null && importInto !== '') { * ten rows, shared by every tenant — and matching on the NAME rather than
for (const product of products) out.set(catalogueKey(product), Number(importInto)); * trusting a remembered id is what keeps this off the app's other
return out; * subcategory table, which carries the same ten names five ids lower.
} */
async function aisleIds(): Promise<Map<string, number>> {
const names = [...new Set(products.map(categoryNameFor).filter(Boolean))];
let resolved: Record<string, number> = {};
if (names.length > 0) {
try { try {
resolved = (await productsApi.resolveCategories(tenantid as number, names)) ?? {}; return aisleIdsFrom(await productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY));
} catch { } catch {
resolved = {}; return aisleIdsFrom(undefined);
}
}
for (const product of products) {
const name = categoryNameFor(product).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 = categoryNameFor(product);
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( function importRowFor(
product: CatalogueProduct, product: CatalogueProduct,
categoryid: number, subcategoryid: number,
): ImportCatalogueProductRequest { ): ImportCatalogueProductRequest {
return { return {
tenantid: tenantid as number, tenantid: tenantid as number,
locationid: locationid as number, locationid: locationid as number,
brand: product.brand, brand: product.brand,
catalogueid: product.id, catalogueid: product.id,
/* This used to read `Number(importInto) || 0`, defended as /* ALWAYS 2, and this is not a placeholder.
"uncategorised beats wrongly categorised — an unclassified product is `getproductsbysubcategory` filters on `categoryid = 2` — the value the
visibly unfinished". It is not visible anywhere: the console lists it app sends — so a per-product categoryid does not label the product, it
like any other product and only the customer app knows it is gone. deletes it from the app's view. What the shopper actually reads as the
A wrongly filed product is at least findable and fixable; one under aisle heading is the subcategory below, which until now was 0 on every
category 0 is returned by no query the app makes. */ product on the platform: one bucket, "Uncategorized", holding the shop.
categoryid, See `appAisle.ts` for the endpoint this is measured against. */
subcategoryid: 0, categoryid: APP_BROWSE_CATEGORY,
subcategoryid,
quantity: 0, quantity: 0,
stocktype: 'in', stocktype: 'in',
status: 'Draft', status: 'Draft',
@@ -408,12 +323,13 @@ export function CatalogueBrowser({
async function importDirect(product: CatalogueProduct) { async function importDirect(product: CatalogueProduct) {
if (!tenantid || !locationid) return; if (!tenantid || !locationid) return;
// Guarded rather than defaulted to 0 — see `hasNoCategory`.
if (chosenCategory === '') return;
const key = catalogueKey(product); const key = catalogueKey(product);
setBusy(key); setBusy(key);
try { try {
await importOne.mutateAsync(importRowFor(product, await categoryIdFor(product))); await importOne.mutateAsync(
importRowFor(product, aisleIdForCategory(categoryNameFor(product), await aisleIds())),
);
setJustImported((set) => new Set(set).add(key)); setJustImported((set) => new Set(set).add(key));
} finally { } finally {
setBusy(null); setBusy(null);
@@ -423,17 +339,6 @@ export function CatalogueBrowser({
const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product)); const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product));
const canImport = Boolean(!isReadOnly && tenantid && locationid && !blockedReason); const canImport = Boolean(!isReadOnly && tenantid && locationid && !blockedReason);
/**
* "Import into" lives in the drawer, at the moment of the decision.
*
* It used to sit in the filter row as well, which put a question about where
* ONE product should be filed next to three controls that change what the
* whole grid shows — and it read as a fourth filter. It is only offered when
* this component performs the import itself: when the caller takes over
* (`onImport`), its own form collects the category along with the price, and
* asking twice on one screen gets two answers that can disagree.
*/
const showCategoryPicker = !isReadOnly && !onImport && categoryOptions.length > 0;
function clearFilters() { function clearFilters() {
setBrand(''); setBrand('');
@@ -453,27 +358,26 @@ export function CatalogueBrowser({
<VStack gap={2}> <VStack gap={2}>
{scope} {scope}
{/* Pulled up onto the tab row's line and right-aligned, so search sits {/* Search, filters and the rail toggle, on their own line.
level with the page selector rather than on a line of its own — the
tab row is mostly empty to the right and was reserving a whole row
below it for four controls.
The pull-up is a CSS offset rather than the controls being rendered They used to be PULLED UP onto the page's tab row by a -42px offset
inside the tab row, because the tab row belongs to the page (`alignWithTabs`, `.catalogue-controls`), because that row looked
(`InventoryPage`) and these controls belong to the browser's own mostly empty to the right. It is not empty any more — Inventory's tab
state. Rendering them there would mean lifting `keyword` and the rail row carries "Upload sheet" at its right end — and the offset does not
toggle into two different pages to keep one row tidy. It reverts to move a narrow control into a gap, it lays a FULL-WIDTH row on top of
its own line below 900px, where the tabs need the width. the whole tab row: measured at 24 → 1090px, covering all four tabs and
the button. Clicks landed on this element and nothing switched tabs,
which read as "the tabs stop working once you open the Catalogue".
That was the second time. The prop's own note recorded it happening to
the platform catalogue's mode toggle, and it was fixed there by not
passing the prop — leaving the offset in place to catch the next row
that grew an action. Two controls cannot share one right-hand corner,
so search keeps its own line and the tab row keeps its button.
It stays above both columns rather than inside the rail: the search It stays above both columns rather than inside the rail: the search
narrows the whole catalogue, and the rail only lists brands. */} narrows the whole catalogue, and the rail only lists brands. */}
<HStack <HStack gap={1} align="center" wrap="wrap" justify="end">
gap={1}
align="center"
wrap="wrap"
justify="end"
{...(alignWithTabs ? { className: 'catalogue-controls' } : {})}
>
{activeFilters.map((filter) => ( {activeFilters.map((filter) => (
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} /> <Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
))} ))}
@@ -603,7 +507,7 @@ export function CatalogueBrowser({
variant="primary" variant="primary"
size="sm" size="sm"
isLoading={importMany.isPending} isLoading={importMany.isPending}
isDisabled={importMany.isPending || hasNoCategory} isDisabled={importMany.isPending}
onClick={() => onClick={() =>
importMany.mutate( importMany.mutate(
rows.filter((product) => selection.has(product.id)), rows.filter((product) => selection.has(product.id)),
@@ -665,10 +569,7 @@ export function CatalogueBrowser({
isImported={importedKeys.has(catalogueKey(open))} isImported={importedKeys.has(catalogueKey(open))}
isBusy={busy === catalogueKey(open)} isBusy={busy === catalogueKey(open)}
actionLabel={actionLabel} actionLabel={actionLabel}
{...(showCategoryPicker {...(canImport && !isReadOnly
? { categoryOptions, categoryid: pickerValue, onCategoryChange: setImportInto }
: {})}
{...(canImport && !isReadOnly && !hasNoCategory
? { ? {
onImport: () => { onImport: () => {
const product = open; const product = open;
@@ -678,14 +579,12 @@ export function CatalogueBrowser({
} }
: {})} : {})}
{...(blockedReason {...(blockedReason
? // The caller's reason wins: "no outlet selected" outranks "no ? // The only reason left is the caller's — "no outlet selected".
// category", because you cannot pick one without the other. // "This merchant has no category yet" used to sit here too and is
// gone: the category is derived from the product and created on
// demand, so there is no longer a state where a merchant has
// nowhere to file something.
{ blockedReason } { blockedReason }
: hasNoCategory
? {
blockedReason:
'This merchant has no product category yet, and a product imported without one is invisible in the customer app. Add a category first.',
}
: {})} : {})}
onClose={() => setOpen(null)} onClose={() => setOpen(null)}
/> />

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Banner } from '@astryxdesign/core/Banner'; import { Banner } from '@astryxdesign/core/Banner';
import { Lightbox } from '@astryxdesign/core/Lightbox'; import { Lightbox } from '@astryxdesign/core/Lightbox';
import { Selector } from '@astryxdesign/core/Selector';
import { Check, DownloadCloud, ImageOff } from 'lucide-react'; import { Check, DownloadCloud, ImageOff } from 'lucide-react';
import type { CatalogueProduct } from '@/api/types'; import type { CatalogueProduct } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer'; import { Drawer } from '@/features/store-admin/Drawer';
@@ -14,6 +13,7 @@ import {
Section, Section,
} from '@/features/store-admin/drawerKit'; } from '@/features/store-admin/drawerKit';
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory'; import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
import { aisleForCategory } from '@/features/store-admin/appAisle';
import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel'; import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel';
/** /**
@@ -40,10 +40,6 @@ export interface CatalogueDetailDrawerProps {
isImported: boolean; isImported: boolean;
isBusy?: boolean; isBusy?: boolean;
actionLabel?: string; actionLabel?: string;
/** The tenant's own categories. Omit to hide the picker. */
categoryOptions?: { value: string; label: string }[];
categoryid?: string;
onCategoryChange?: (value: string) => void;
/** Absent when the caller has nowhere to import to yet. */ /** Absent when the caller has nowhere to import to yet. */
onImport?: () => void; onImport?: () => void;
/** Shown in place of the action when importing is unavailable. */ /** Shown in place of the action when importing is unavailable. */
@@ -56,14 +52,16 @@ export function CatalogueDetailDrawer({
isImported, isImported,
isBusy, isBusy,
actionLabel = 'Add to my products', actionLabel = 'Add to my products',
categoryOptions,
categoryid = '',
onCategoryChange,
onImport, onImport,
blockedReason, blockedReason,
onClose, onClose,
}: CatalogueDetailDrawerProps) { }: CatalogueDetailDrawerProps) {
/* Which of the 31 this product will actually be filed under, and why. */ /* Which of the 31 this product will be filed under, and why.
Shown, not asked. It used to be a dropdown here, defaulting to whatever the
merchant's list happened to hold — which is how a masala arrived on a shelf
as "Category 2". The classification is the catalogue team's, the same one
the customer app's filter is built on, so there is nothing for a person to
decide: what is left is telling them what it decided. */
const suggested = categoryForCatalogueProduct({ const suggested = categoryForCatalogueProduct({
catalogueCategory: product.category, catalogueCategory: product.category,
title: product.product_name, title: product.product_name,
@@ -71,6 +69,9 @@ export function CatalogueDetailDrawer({
packSize: product.size ?? '', packSize: product.size ?? '',
}); });
/* The app groups by subcategory, not category — see `appAisle.ts`. */
const aisle = aisleForCategory(suggested.category);
const images = product.images ?? []; const images = product.images ?? [];
const [heroAt, setHeroAt] = useState(0); const [heroAt, setHeroAt] = useState(0);
const [isZoomed, setIsZoomed] = useState(false); const [isZoomed, setIsZoomed] = useState(false);
@@ -255,7 +256,7 @@ export function CatalogueDetailDrawer({
{/* The action, last, because everything above is what the decision is {/* The action, last, because everything above is what the decision is
made on. The button itself lives in the fixed bar; what stays here is made on. The button itself lives in the fixed bar; what stays here is
the choice it depends on and the warning about what it does not do. */} where it will be filed and the warning about what importing does not do. */}
{isImported ? ( {isImported ? (
<Banner <Banner
status="success" status="success"
@@ -268,36 +269,37 @@ export function CatalogueDetailDrawer({
) : onImport ? ( ) : onImport ? (
<Section title="Import into my products"> <Section title="Import into my products">
<DrawerCard> <DrawerCard>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 14 }}> <Row
{categoryOptions ? ( label="Filed under"
<Selector value={<Badge label={suggested.category} colour="var(--color-brand)" />}
label="Category" />
size="sm" {/* The heading a shopper actually reads. The catalogue's 31
options={categoryOptions} categories are finer than the app's ten aisles, so the two are
value={categoryid} shown side by side rather than one standing in for the other —
onChange={(value) => onCategoryChange?.(value)} and a product with no aisle is told so here, not discovered
/* No "leave uncategorised" placeholder, and the advice that missing from the app later. */}
used to sit here — "uncategorised beats wrongly categorised" <Row
— is gone because it is false. A product filed under label="Shown in the app under"
category 0 is returned by NO query the customer app makes: value={
its endpoint rejects categoryid 0 outright. Seven products aisle ? (
across three tenants sat invisible that way, six outlets <Badge label={aisle} colour="#0f8a5f" />
showing shoppers an empty shop, while the console listed ) : (
their stock as normal. A wrongly filed product is at least <span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
findable and fixable; an unfiled one was neither. */ )
placeholder={`Suggested: ${suggested.category}`}
description={
suggested.rule === 'catalogue'
? `Left alone, this is filed under ${suggested.category} — the catalogue's own category, created for your shop if you do not have it yet. Choose one here to override.`
: product.category
? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it goes to ${suggested.category} instead. Choose one here to override.`
: `The catalogue does not categorise this one, so it goes to ${suggested.category}. Choose one here to override.`
} }
/> />
) : null} <div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
<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)' }}>
Adds this product with no price. It reaches no shop and cannot be sold until you {suggested.rule === 'catalogue'
price and publish it. ? `The catalogue's own category for this product.`
: product.category
? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it is classified as ${suggested.category} instead, which is what the app's filter can show.`
: `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`}
</span>
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
{aisle
? 'Adds this product with no price. It reaches no shop and cannot be sold until you price and publish it.'
: 'Adds this product with no price, and with no aisle — the app will list it under “Uncategorized” until it can be classified. Price and publish it to put it on sale.'}
</span> </span>
</div> </div>
</DrawerCard> </DrawerCard>

View File

@@ -1,43 +0,0 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { APP_BROWSE_CATEGORY, categoryOptionsFor } from './tenantCategories';
test('a new tenant is offered a category, so it can import its first product', () => {
// The regression. `gettenantcategories` is derived from products the tenant
// ALREADY has, so a shop with none gets []. CatalogueBrowser then refuses
// every import ("no category to file into") and the only way to acquire a
// category is to import a product. Measured on 2026-09-02: 37 of the 45 most
// recent tenants had zero categories and could not add their first catalogue
// product at all.
const options = categoryOptionsFor([], false);
assert.equal(options.length, 1);
assert.equal(options[0]?.value, String(APP_BROWSE_CATEGORY));
});
test('that first product is filed where the customer app will actually find it', () => {
// Not merely "some category". Anything other than 2 imports a product no
// shopper can see — which is the exact failure the refusal existed to
// prevent, so a floor that got this wrong would be worse than the deadlock.
assert.equal(categoryOptionsFor([], false)[0]?.value, '2');
});
test("an established tenant's own categories are left alone", () => {
const own = [
{ categoryid: 2, categoryname: 'Grocery' },
{ categoryid: 9, categoryname: 'Chilled' },
];
assert.deepEqual(categoryOptionsFor(own, false), [
{ value: '2', label: 'Grocery' },
{ value: '9', label: 'Chilled' },
]);
});
test('nothing is offered while the real answer is still in flight', () => {
// Showing "General" mid-load and swapping it for the tenant's real list a
// moment later moves where a product lands without the merchant touching it.
assert.deepEqual(categoryOptionsFor(undefined, true), []);
});
test('a missing list settles the same way as an empty one', () => {
assert.equal(categoryOptionsFor(undefined, false).length, 1);
});

View File

@@ -1,5 +1,3 @@
import type { ProductCategory } from '@/api/types';
/** /**
* The category the customer app browses. * The category the customer app browses.
* *
@@ -10,46 +8,18 @@ import type { ProductCategory } from '@/api/types';
* Measured, not assumed: every tenant on the platform that has products reports * Measured, not assumed: every tenant on the platform that has products reports
* category 2 and nothing else, and category 2 carries the ten real retail * category 2 and nothing else, and category 2 carries the ten real retail
* subcategories. * subcategories.
*
* It survives as a LAST RESORT only. Nothing picks a category any longer: every
* product is classified by `productCategory.ts` and the name exchanged for this
* merchant's id by `resolvecategories`. This is what a row falls back to when
* that call cannot be made at all — better a product on the shelf under the
* aisle the app browses than a product filed under 0, which no browse query
* returns.
*
* What used to live beside it, `categoryOptionsFor`, is gone with the pickers
* it fed. It offered a tenant's own categories and this constant as a floor,
* and its floor was the visible symptom: a merchant with no list yet was shown
* "General (the category the app shows)", chose it because it was the only
* entry, and every product they imported arrived as "Category 2".
*/ */
export const APP_BROWSE_CATEGORY = 2; export const APP_BROWSE_CATEGORY = 2;
export interface CategoryOption {
value: string;
label: string;
}
/**
* A tenant's categories, with a floor for one that has none yet.
*
* `gettenantcategories` is synthesised from the categories a tenant's products
* ALREADY use. That makes it right for an established shop and empty for a new
* one — and a new shop is exactly who is importing their first product.
*
* Without a floor the two rules deadlock: the catalogue refuses to import
* without a category (correctly — `categoryid` 0 is invisible to shoppers), and
* the only way to acquire a category is to import a product. Measured on
* 2026-09-02: tenant 1148 had 0 products and 0 categories and could not add its
* first one from the catalogue at all.
*
* The floor is the category the app actually browses, so a product filed under
* it is sellable rather than merely stored. Note what is deliberately NOT
* offered: `getproductcategories`, the master table, returns `1001: vegetables`
* for every tenant — it looks like a perfectly good category and is a trap,
* because the app never asks for it.
*
* `isLoading` returns an empty list rather than the floor: offering it while the
* real answer is still in flight would show a shop a category it does not use
* and then swap it underneath them.
*/
export function categoryOptionsFor(
categories: readonly ProductCategory[] | undefined,
isLoading: boolean,
): CategoryOption[] {
const own = (categories ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
}));
if (own.length > 0) return own;
if (isLoading) return [];
return [{ value: String(APP_BROWSE_CATEGORY), label: 'General (the category the app shows)' }];
}

View File

@@ -1,9 +1,9 @@
/** /**
* Who an uploaded sheet is for. * Who an uploaded sheet is for.
* *
* A sheet becomes stock on one shelf, so the upload has to name a tenant, a * A sheet becomes stock on one shelf, so the upload has to name a tenant and a
* branch and a category before it can do anything — and where those come from * branch before it can do anything — and where those come from depends entirely
* depends entirely on who is signed in: * on who is signed in:
* *
* - A **Nearle Admin** works across every merchant. Nothing in their session * - A **Nearle Admin** works across every merchant. Nothing in their session
* says which one, so they are ASKED, and the upload stays blocked until * says which one, so they are ASKED, and the upload stays blocked until
@@ -16,21 +16,27 @@
* The branch matters as much as the tenant and is easier to forget: stock is * The branch matters as much as the tenant and is easier to forget: stock is
* held per outlet, so a sheet uploaded against the wrong branch loads a shelf * held per outlet, so a sheet uploaded against the wrong branch loads a shelf
* nobody is standing at. * nobody is standing at.
*
* A CATEGORY IS NOT ASKED FOR, and its absence is deliberate. It used to be the
* third question here, and one answer was applied to every row in the sheet —
* so a shop uploading its whole range had butter, bleach and biryani masala all
* filed under the same aisle, and the app's category filter showed exactly that.
* The category is now worked out per product from the catalogue team's ladder
* (`productCategory.ts`) and resolved to an id at shelving time, which is a
* classification and not a preference: there is nothing here for anyone to get
* right or wrong.
*/ */
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { useTenantCategories, useTenantLocations, useTenants } from '@/queries/hooks'; import { useTenantLocations, useTenants } from '@/queries/hooks';
import { categoryOptionsFor } from '@/features/catalogue/tenantCategories';
/** Everything an upload needs before it can be turned into stock. */ /** Everything an upload needs before it can be turned into stock. */
export interface ImportTarget { export interface ImportTarget {
tenantid: number; tenantid: number;
locationid: number; locationid: number;
/** Used for any sheet row that names no category of its own. */
categoryid: number;
} }
export interface ImportScopeProps { export interface ImportScopeProps {
@@ -48,7 +54,6 @@ export interface ImportScopeProps {
export function ImportScope({ value, onChange, isTenantFixed = false }: ImportScopeProps) { export function ImportScope({ value, onChange, isTenantFixed = false }: ImportScopeProps) {
const tenants = useTenants({ pageno: 1, pagesize: 200 }); const tenants = useTenants({ pageno: 1, pagesize: 200 });
const locations = useTenantLocations(value.tenantid); const locations = useTenantLocations(value.tenantid);
const categories = useTenantCategories(value.tenantid);
const tenantOptions = useMemo( const tenantOptions = useMemo(
() => () =>
@@ -70,39 +75,11 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
[locations.data], [locations.data],
); );
/**
* The tenant's own categories, with a floor for a merchant that has none yet.
*
* `gettenantcategories` is synthesised from the categories a tenant's products
* ALREADY use, which makes it right for an established shop and empty for a
* new one — and a new shop is exactly who is doing their first import. Without
* a floor the picker would be empty, the upload blocked, and the only way to
* get a category would be to import a product, which is the thing being
* blocked.
*
* The floor is the category the customer app actually browses. Measured, not
* assumed: every tenant with products reports category 2 and nothing else,
* category 2 carries the ten real retail subcategories (Vegetables & Fruits,
* Dairy Deli & Egg, and so on), and `scripts/appgap.mjs` shows the app asking
* for `categoryid: 2`.
*
* Note what is deliberately NOT offered: `getproductcategories`, the master
* table, returns `1001: vegetables` for every tenant. It looks like a
* perfectly good category and is a trap — the app does not browse it, so
* anything filed there is invisible to shoppers.
*/
const categoryOptions = useMemo(
() => categoryOptionsFor(categories.data, categories.isLoading),
[categories.data, categories.isLoading],
);
/** /**
* A single branch is not a choice, so it is made rather than offered. * A single branch is not a choice, so it is made rather than offered.
* *
* Most merchants run one outlet. Leaving it unpicked would block the upload * Most merchants run one outlet. Leaving it unpicked would block the upload
* behind a dropdown with one entry — and the same applies to the category, * behind a dropdown with one entry.
* where `gettenantcategories` reports one usable value for every tenant seen
* so far.
*/ */
useEffect(() => { useEffect(() => {
if (!value.tenantid) return; if (!value.tenantid) return;
@@ -111,13 +88,6 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
} }
}, [branchOptions, value, onChange]); }, [branchOptions, value, onChange]);
useEffect(() => {
if (!value.tenantid) return;
if (!value.categoryid && categoryOptions.length === 1) {
onChange({ ...value, categoryid: Number(categoryOptions[0]!.value) });
}
}, [categoryOptions, value, onChange]);
return ( return (
<VStack gap={1.5}> <VStack gap={1.5}>
{!isTenantFixed ? ( {!isTenantFixed ? (
@@ -129,9 +99,9 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
placeholder={tenants.isLoading ? 'Loading merchants…' : 'Choose a merchant'} placeholder={tenants.isLoading ? 'Loading merchants…' : 'Choose a merchant'}
description="Whose catalogue this sheet is loading. Nothing is uploaded until this is set." description="Whose catalogue this sheet is loading. Nothing is uploaded until this is set."
onChange={(next) => onChange={(next) =>
// Changing the merchant clears the branch and category with it. // Changing the merchant clears the branch with it. Keeping it
// Keeping them would leave another tenant's branch id attached to // would leave another tenant's branch id attached to this one — an
// this one — an id that is valid-looking and wrong. // id that is valid-looking and wrong.
onChange({ tenantid: Number(next) || undefined }) onChange({ tenantid: Number(next) || undefined })
} }
/> />
@@ -154,40 +124,18 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
onChange={(next) => onChange({ ...value, locationid: Number(next) || undefined })} onChange={(next) => onChange({ ...value, locationid: Number(next) || undefined })}
/> />
<Selector
/* "for rows that do not name one" was the old label, and it stopped
being true when the sheet stopped carrying a category. No row names
one now, so it read as a rare fallback when it is in fact the only
source — and a field that looks optional is a field people skip. */
label="Category"
size="sm"
options={categoryOptions}
value={value.categoryid ? String(value.categoryid) : ''}
placeholder={!value.tenantid ? 'Choose a merchant first' : 'Choose a category'}
description="Applied to every product in the sheet. The customer app browses by category, and one filed without a category cannot be found there at all — which is why this is asked here rather than typed on every row."
isDisabled={!value.tenantid}
onChange={(next) => onChange({ ...value, categoryid: Number(next) || undefined })}
/>
{value.tenantid && branchOptions.length === 0 && !locations.isLoading ? ( {value.tenantid && branchOptions.length === 0 && !locations.isLoading ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}> <Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
This merchant has no branches, so there is nowhere for stock to land. Create one first. This merchant has no branches, so there is nowhere for stock to land. Create one first.
</Text> </Text>
) : null} ) : null}
{value.tenantid && (categories.data ?? []).length === 0 && !categories.isLoading ? (
<Text type="body" size="xsm" color="secondary">
This merchant has no categories of its own yet, so the one the customer app browses is
offered instead. Their first import is what creates the list.
</Text>
) : null}
</VStack> </VStack>
); );
} }
/** True when every field an import needs has been answered. */ /** True when every field an import needs has been answered. */
export function isTargetComplete(value: Partial<ImportTarget>): value is ImportTarget { export function isTargetComplete(value: Partial<ImportTarget>): value is ImportTarget {
return Boolean(value.tenantid && value.locationid && value.categoryid); return Boolean(value.tenantid && value.locationid);
} }
/** What is still missing, in words, for a blocked-reason line. */ /** What is still missing, in words, for a blocked-reason line. */
@@ -195,7 +143,6 @@ export function describeMissingTarget(value: Partial<ImportTarget>): string | nu
const missing = [ const missing = [
!value.tenantid ? 'a merchant' : null, !value.tenantid ? 'a merchant' : null,
!value.locationid ? 'a branch' : null, !value.locationid ? 'a branch' : null,
!value.categoryid ? 'a category' : null,
].filter(Boolean); ].filter(Boolean);
if (missing.length === 0) return null; if (missing.length === 0) return null;
return `Choose ${missing.join(', ')} before uploading — a sheet becomes stock on one shelf, and this is which.`; return `Choose ${missing.join(', ')} before uploading — a sheet becomes stock on one shelf, and this is which.`;

View File

@@ -43,7 +43,7 @@ import { usePaged } from '@/components/usePaged';
interface PreviewRow extends Record<string, unknown> { interface PreviewRow extends Record<string, unknown> {
productname: string; productname: string;
productsku: string; productsku: string;
categoryid: number; category: string;
retailprice: number; retailprice: number;
productcost: number; productcost: number;
quantity: number; quantity: number;
@@ -211,7 +211,9 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
await uploadsApi.record({ await uploadsApi.record({
tenantid: target.tenantid, tenantid: target.tenantid,
locationid: target.locationid, locationid: target.locationid,
categoryid: target.categoryid, // Nothing picks a category any more, so the receipt's column is
// written 0 and never read back: `shelveBatch` classifies each row.
categoryid: 0,
batchid: submitted.batch_id, batchid: submitted.batch_id,
filename: file.name, filename: file.name,
sender: submitted.submitted_by ?? '', sender: submitted.submitted_by ?? '',
@@ -315,9 +317,20 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
</VStack> </VStack>
), ),
}, },
/* No Category column. The console no longer reads one from the sheet — it /* The category, shown because nobody chooses it any more.
is chosen once above and applied to every row — so a column here would It is worked out per row before the upload, so this column is the one
show 0 on every line and invite somebody to "fix" it in the file. */ place the operator can see what each product will be filed under while
the file is still theirs to correct. */
{
key: 'category',
header: 'Category',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" color={row.category ? undefined : 'secondary'}>
{row.category || 'General'}
</Text>
),
},
{ {
key: 'retailprice', key: 'retailprice',
header: 'Retail', header: 'Retail',
@@ -355,7 +368,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
const preview: PreviewRow[] = (parsed?.rows ?? []).map((row: SheetProductRow) => ({ const preview: PreviewRow[] = (parsed?.rows ?? []).map((row: SheetProductRow) => ({
productname: row.productname, productname: row.productname,
productsku: row.productsku, productsku: row.productsku,
categoryid: row.categoryid, category: row.category ?? '',
retailprice: row.retailprice, retailprice: row.retailprice,
productcost: row.productcost, productcost: row.productcost,
quantity: row.quantity, quantity: row.quantity,
@@ -730,10 +743,10 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
and an opening stock are used when present, and a SKU makes the match back to your rows and an opening stock are used when present, and a SKU makes the match back to your rows
exact rather than by name. exact rather than by name.
<br /> <br />
You do not need a categoryid column — the category chosen above applies to every row You are not asked for a category, and a categoryid column is ignored if you include
that does not name one. Leaving it out is the safer shape: a wrong id in a sheet is one. Each product is filed by the platform's own classification — the sheet's category
accepted silently and hides the product from shoppers, while the picker can only offer column if it has one, then the product registry, then its name — into one of the 31
ids the customer app actually browses. categories the customer app browses, so a mixed sheet no longer lands in a single aisle.
</Text> </Text>
{parseError ? ( {parseError ? (

View File

@@ -96,7 +96,7 @@ test('one call carries the price and the opening stock together', () => {
const { requests } = buildImportRequests(plan, { const { requests } = buildImportRequests(plan, {
tenantid: 1141, tenantid: 1141,
locationid: 1179, locationid: 1179,
fallbackCategoryId: 2, aisleIds: new Map(),
catalogueIds: ids, catalogueIds: ids,
}); });
@@ -110,17 +110,19 @@ test('one call carries the price and the opening stock together', () => {
assert.equal(req.locationid, 1179); assert.equal(req.locationid, 1179);
}); });
// categoryid 0 is the state the customer app rejects outright — it has cost this // categoryid 2 is not a label, it is the value the app FILTERS on — see
// system seven invisible products already. // `appAisle.ts`. A per-product categoryid does not categorise the product, it
test('a blank sheet category falls back to the operator choice, never zero', () => { // removes it from the app's browse entirely.
const plan = planOpeningStock([manifest()], [sheetRow({ categoryid: 0 })]); test('categoryid is always the one the app asks for', () => {
const plan = planOpeningStock([manifest()], [sheetRow({ category: 'Dairy', categoryid: 91 })]);
const { requests } = buildImportRequests(plan, { const { requests } = buildImportRequests(plan, {
tenantid: 1141, tenantid: 1141,
locationid: 1179, locationid: 1179,
fallbackCategoryId: 2, aisleIds: new Map([['dairy, deli & egg', 15]]),
catalogueIds: ids, catalogueIds: ids,
}); });
assert.equal(requests[0]?.categoryid, 2); assert.equal(requests[0]?.categoryid, 2, 'never 91 — the app asks for 2 and would miss it');
assert.equal(requests[0]?.subcategoryid, 15, 'the aisle is where the classification lands');
}); });
// A ₹0 product can be ordered for nothing. Refused and named. // A ₹0 product can be ordered for nothing. Refused and named.
@@ -129,7 +131,7 @@ test('an unpriced row is refused rather than listed at zero', () => {
const out = buildImportRequests(plan, { const out = buildImportRequests(plan, {
tenantid: 1141, tenantid: 1141,
locationid: 1179, locationid: 1179,
fallbackCategoryId: 2, aisleIds: new Map(),
catalogueIds: ids, catalogueIds: ids,
}); });
assert.equal(out.requests.length, 0); assert.equal(out.requests.length, 0);
@@ -141,7 +143,7 @@ test('a product whose image_id resolves to nothing is reported, not skipped', ()
const out = buildImportRequests(plan, { const out = buildImportRequests(plan, {
tenantid: 1141, tenantid: 1141,
locationid: 1179, locationid: 1179,
fallbackCategoryId: 2, aisleIds: new Map(),
catalogueIds: new Map(), catalogueIds: new Map(),
}); });
assert.equal(out.requests.length, 0); assert.equal(out.requests.length, 0);
@@ -150,20 +152,20 @@ test('a product whose image_id resolves to nothing is reported, not skipped', ()
test('a fractional or negative opening stock is made sane', () => { test('a fractional or negative opening stock is made sane', () => {
const plan = planOpeningStock([manifest()], [sheetRow({ quantity: -5 })]); const plan = planOpeningStock([manifest()], [sheetRow({ quantity: -5 })]);
const a = buildImportRequests(plan, { tenantid: 1, locationid: 2, fallbackCategoryId: 2, catalogueIds: ids }); const a = buildImportRequests(plan, { tenantid: 1, locationid: 2, aisleIds: new Map(), catalogueIds: ids });
assert.equal(a.requests[0]?.quantity, 0, 'negative opening stock is not a receipt'); assert.equal(a.requests[0]?.quantity, 0, 'negative opening stock is not a receipt');
const plan2 = planOpeningStock([manifest()], [sheetRow({ quantity: 3.7 })]); const plan2 = planOpeningStock([manifest()], [sheetRow({ quantity: 3.7 })]);
const b = buildImportRequests(plan2, { tenantid: 1, locationid: 2, fallbackCategoryId: 2, catalogueIds: ids }); const b = buildImportRequests(plan2, { tenantid: 1, locationid: 2, aisleIds: new Map(), catalogueIds: ids });
assert.equal(b.requests[0]?.quantity, 3); assert.equal(b.requests[0]?.quantity, 3);
}); });
/* ── A sheet with no categoryid column at all ─────────────────────────────── */ /* ── A sheet with no categoryid column at all ─────────────────────────────── */
// parseProductSheet defaults a missing categoryid to 0, and 0 is the value the // parseProductSheet defaults a missing categoryid to 0, and 0 is the value the
// customer app rejects outright. The picker's choice is what must fill it — // customer app rejects outright. It must never reach the request whatever the
// otherwise dropping the column would silently hide every product uploaded. // sheet holds — otherwise dropping the column would hide every product uploaded.
test('a sheet with no category column gets the operator choice on every row', () => { test('a sheet with nothing to classify still never files a row under zero', () => {
const rows = [ const rows = [
sheetRow({ productname: 'Amul Butter 100g', productsku: 'A1', categoryid: 0 }), sheetRow({ productname: 'Amul Butter 100g', productsku: 'A1', categoryid: 0 }),
sheetRow({ productname: 'Amul Ghee 1L', productsku: 'A2', categoryid: 0 }), sheetRow({ productname: 'Amul Ghee 1L', productsku: 'A2', categoryid: 0 }),
@@ -177,7 +179,7 @@ test('a sheet with no category column gets the operator choice on every row', ()
const { requests } = buildImportRequests(plan, { const { requests } = buildImportRequests(plan, {
tenantid: 1141, tenantid: 1141,
locationid: 1179, locationid: 1179,
fallbackCategoryId: 2, aisleIds: new Map(),
catalogueIds: new Map([ catalogueIds: new Map([
['amul_amul_butter_100g', 42], ['amul_amul_butter_100g', 42],
['amul_amul_ghee_1l', 43], ['amul_amul_ghee_1l', 43],
@@ -187,20 +189,80 @@ test('a sheet with no category column gets the operator choice on every row', ()
assert.equal(requests.length, 2); assert.equal(requests.length, 2);
for (const req of requests) { for (const req of requests) {
assert.equal(req.categoryid, 2, 'never 0 — that is the invisible state'); assert.equal(req.categoryid, 2, 'never 0 — that is the invisible state');
assert.equal(req.subcategoryid, 0, "no classification means the app own Uncategorized bucket");
} }
}); });
// The sheet no longer supplies a category at all — parseProductSheet stopped // A stray subcategoryid in a sheet is honoured only when nothing classified the
// mapping the column, so every row arrives as 0 and the operator answer is the // row — the classification always wins, because a number typed into a column
// only source. This is the guard on that: if a row ever carries a stray value // nobody documents is the least trustworthy thing in the file.
// it is still used, but nothing in the parser produces one any more. test('the classification beats a subcategoryid typed into the sheet', () => {
test('the operator answer applies even when a row somehow carries zero', () => { const plan = planOpeningStock(
const plan = planOpeningStock([manifest()], [sheetRow({ categoryid: 0 })]); [manifest()],
[sheetRow({ category: 'Dairy', subcategoryid: 99 })],
);
const { requests } = buildImportRequests(plan, { const { requests } = buildImportRequests(plan, {
tenantid: 1141, tenantid: 1141,
locationid: 1179, locationid: 1179,
fallbackCategoryId: 2, aisleIds: new Map([['dairy, deli & egg', 15]]),
catalogueIds: ids, catalogueIds: ids,
}); });
assert.equal(requests[0]?.categoryid, 2); assert.equal(requests[0]?.subcategoryid, 15);
});
/* ── The classification decides the aisle, not one answer for the sheet ───── */
// This is the whole point of removing the picker. One id used to be applied to
// every row, so a shop uploading its range had butter, bleach and biryani
// masala filed together — and the app's browse showed exactly that.
test('each row takes its own aisle, not one answer for the sheet', () => {
const rows = [
sheetRow({ productname: 'Amul Butter 100g', productsku: 'A1', category: 'Dairy', categoryid: 0 }),
sheetRow({
productname: 'Aachi Biryani Masala 50g',
productsku: 'A2',
category: 'Spices & Masalas',
categoryid: 0,
}),
];
const products = [
manifest({ product_sku: 'A1' }),
manifest({
image_id: 'aachi_biryani_masala_50g',
product_name: 'Aachi Biryani Masala 50g',
product_sku: 'A2',
}),
];
const { requests } = buildImportRequests(planOpeningStock(products, rows), {
tenantid: 1141,
locationid: 1179,
// Keyed by AISLE name, lower-cased — the ten rows the app groups by.
aisleIds: new Map([
['dairy, deli & egg', 15],
['foodgrains & pulses', 18],
]),
catalogueIds: new Map([
['amul_amul_butter_100g', 42],
['aachi_biryani_masala_50g', 43],
]),
});
assert.equal(requests.length, 2);
assert.equal(requests[0]?.subcategoryid, 15, 'butter to Dairy, Deli & Egg');
assert.equal(requests[1]?.subcategoryid, 18, 'masala to Foodgrains & Pulses, not the same aisle');
});
// Casing and stray spaces come from the ladder's own names and from whatever
// the merchant's category list already holds; neither should cost a product its
// aisle and send it to the fallback.
test('a name is matched regardless of casing or surrounding space', () => {
const plan = planOpeningStock([manifest()], [sheetRow({ category: ' Dairy ', categoryid: 0 })]);
const { requests } = buildImportRequests(plan, {
tenantid: 1141,
locationid: 1179,
aisleIds: new Map([['dairy, deli & egg', 15]]),
catalogueIds: ids,
});
assert.equal(requests[0]?.subcategoryid, 15);
}); });

View File

@@ -40,6 +40,8 @@
*/ */
import type { IngestProduct } from '@/api/ingest'; import type { IngestProduct } from '@/api/ingest';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { aisleIdForCategory } from '@/features/store-admin/appAisle';
import type { SheetProductRow } from '@/api/products'; import type { SheetProductRow } from '@/api/products';
/** Where a product's price and opening stock came from. */ /** Where a product's price and opening stock came from. */
@@ -148,8 +150,14 @@ export interface ImportRequestRow {
export interface BuildOptions { export interface BuildOptions {
tenantid: number; tenantid: number;
locationid: number; locationid: number;
/** Applied to any row whose sheet left `categoryid` blank. */ /**
fallbackCategoryId: number; * Lower-cased AISLE NAME to the id the customer app groups by.
*
* Each row carries the category the classification ladder decided; that name
* folds into one of the app's ten aisles, and this maps the aisle to the
* `productsubcategories` id. Nobody picks it — see `shelveBatch`.
*/
aisleIds: ReadonlyMap<string, number>;
/** `image_id` → the catalogue row id `importcatalogueproduct` addresses. */ /** `image_id` → the catalogue row id `importcatalogueproduct` addresses. */
catalogueIds: ReadonlyMap<string, number>; catalogueIds: ReadonlyMap<string, number>;
} }
@@ -178,7 +186,7 @@ export interface BuildResult {
* sent and both come back named. * sent and both come back named.
*/ */
export function buildImportRequests(plan: StockPlan, options: BuildOptions): BuildResult { export function buildImportRequests(plan: StockPlan, options: BuildOptions): BuildResult {
const { tenantid, locationid, fallbackCategoryId, catalogueIds } = options; const { tenantid, locationid, aisleIds, catalogueIds } = options;
const requests: ImportRequestRow[] = []; const requests: ImportRequestRow[] = [];
const unresolved: IngestProduct[] = []; const unresolved: IngestProduct[] = [];
@@ -202,11 +210,16 @@ export function buildImportRequests(plan: StockPlan, options: BuildOptions): Bui
locationid, locationid,
brand: product.brand, brand: product.brand,
catalogueid, catalogueid,
// The sheet's own category when it named one, the operator's choice // ALWAYS 2. `getproductsbysubcategory` filters on `categoryid = 2`, the
// otherwise. Never 0 — a product filed under 0 is invisible to shoppers // value the app sends, so a per-product categoryid removes the product
// and no API could repair it until recently. // from the app rather than labelling it. Never 0 either — that is the
categoryid: Number(row.categoryid) || fallbackCategoryId, // state the browse endpoint rejects outright.
subcategoryid: Number(row.subcategoryid) || 0, categoryid: APP_BROWSE_CATEGORY,
// The aisle heading a shopper reads. 0 when the row could not be
// classified, which puts it in the app's own "Uncategorized" bucket —
// honest, and the state every product on the platform was already in.
subcategoryid:
aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0,
// The opening stock. This is the whole point of the flow: one call writes // The opening stock. This is the whole point of the flow: one call writes
// the product, puts it on this outlet's shelf, and records the receipt. // the product, puts it on this outlet's shelf, and records the receipt.
quantity: Math.max(0, Math.trunc(Number(row.quantity) || 0)), quantity: Math.max(0, Math.trunc(Number(row.quantity) || 0)),

View File

@@ -18,13 +18,13 @@
import { catalogueApi } from '@/api/catalogue'; import { catalogueApi } from '@/api/catalogue';
import { productsApi, type SheetProductRow } from '@/api/products'; import { productsApi, type SheetProductRow } from '@/api/products';
import { productsOf, type IngestBatch } from '@/api/ingest'; import { productsOf, type IngestBatch } from '@/api/ingest';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { aisleIdsFrom } from '@/features/store-admin/appAisle';
import { buildImportRequests, planOpeningStock, type StockPlan } from './openingStock'; import { buildImportRequests, planOpeningStock, type StockPlan } from './openingStock';
export interface ShelveTarget { export interface ShelveTarget {
tenantid: number; tenantid: number;
locationid: number; locationid: number;
/** Applied to every row; no sheet column carries one any more. */
categoryid: number;
} }
export interface ShelveResult { export interface ShelveResult {
@@ -84,10 +84,38 @@ export async function shelveBatch(
} }
} }
/**
* The aisles, worked out rather than asked for.
*
* Every row already carries the category NAME the classification ladder
* decided for it — the sheet's own value, then the registry, then the
* lexicon, then "General". What the customer app groups by is not that
* category but `products.subcategoryid`, one of ten platform rows, so the
* name is folded into an aisle and the aisle looked up by name here.
*
* There is nothing left for an operator to choose, and the choice they used
* to be given was the whole bug twice over: it applied one id to an entire
* sheet, and it applied it to the wrong column — `categoryid`, which the app
* uses as a FILTER (it asks for 2) rather than as a label.
*
* A failure here is not fatal. The lookup falls back to the ids last read
* from the platform, and a row that still cannot be placed goes out as
* subcategoryid 0, which the app shows under "Uncategorized" — the state
* every product was already in, not a new harm.
*/
let aisleIds: ReadonlyMap<string, number>;
try {
aisleIds = aisleIdsFrom(
await productsApi.subCategories(target.tenantid, APP_BROWSE_CATEGORY),
);
} catch {
aisleIds = aisleIdsFrom(undefined);
}
const { requests, unresolved, unpriced } = buildImportRequests(plan, { const { requests, unresolved, unpriced } = buildImportRequests(plan, {
tenantid: target.tenantid, tenantid: target.tenantid,
locationid: target.locationid, locationid: target.locationid,
fallbackCategoryId: target.categoryid, aisleIds,
catalogueIds, catalogueIds,
}); });

View File

@@ -82,7 +82,6 @@ export function GlobalCataloguePage() {
<CatalogueBrowser <CatalogueBrowser
tenantid={undefined} tenantid={undefined}
locationid={undefined} locationid={undefined}
categoryOptions={[]}
actionLabel="Add to store" actionLabel="Add to store"
isReadOnly isReadOnly
/> />

View File

@@ -1,8 +1,5 @@
import { useMemo } from 'react';
import { useTenantCategories } from '@/queries/hooks';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser'; import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { useBranchScope } from './BranchScope'; import { useBranchScope } from './BranchScope';
import { categoryOptionsFor } from '@/features/catalogue/tenantCategories';
/** /**
* The Store Admin's half of the catalogue: browse it, add to your own products. * The Store Admin's half of the catalogue: browse it, add to your own products.
@@ -26,35 +23,11 @@ export function CataloguePanel() {
*/ */
const locationid = current?.locationid ?? branches[0]?.locationid; const locationid = current?.locationid ?? branches[0]?.locationid;
const categories = useTenantCategories(tenantid);
/**
* No "Leave uncategorised" entry, and its absence is the fix.
*
* It sat first in this list, so it was what the picker showed until somebody
* changed it — and it wrote `categoryid` 0, which the customer app cannot
* return under any category it asks for. It was not an opt-out from filing a
* product; it was an opt-out from selling it, offered as the default.
*
* A tenant with no categories of its own is not refused either, which was
* the overcorrection: the refusal was right about `categoryid` 0 and wrong
* about who it caught. `gettenantcategories` is derived from products a
* tenant already has, so the shops it blocked were new ones importing their
* first product — 37 of the 45 most recent tenants. See
* `categoryOptionsFor` for the floor that resolves it.
*/
const categoryOptions = useMemo(
() => categoryOptionsFor(categories.data, categories.isLoading),
[categories.data, categories.isLoading],
);
return ( return (
<CatalogueBrowser <CatalogueBrowser
tenantid={tenantid || undefined} tenantid={tenantid || undefined}
locationid={locationid} locationid={locationid}
categoryOptions={categoryOptions}
actionLabel="Add to my products" actionLabel="Add to my products"
alignWithTabs
{...(!tenantid || !locationid {...(!tenantid || !locationid
? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' } ? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' }
: {})} : {})}

View File

@@ -4,7 +4,7 @@ import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
import { productsApi } from '@/api/products'; import { productsApi } from '@/api/products';
import type { Product } from '@/api/types'; import type { Product } from '@/api/types';
import { queryKeys } from '@/queries/keys'; import { queryKeys } from '@/queries/keys';
import { useCatalogueProduct } from '@/queries/hooks'; import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
import { useBranchScope } from './BranchScope'; import { useBranchScope } from './BranchScope';
import { Drawer } from './Drawer'; import { Drawer } from './Drawer';
import { import {
@@ -67,6 +67,13 @@ export function ProductDrawer({
const [confirmHide, setConfirmHide] = useState(false); const [confirmHide, setConfirmHide] = useState(false);
const [shot, setShot] = useState(0); const [shot, setShot] = useState(0);
/* The aisle name for this product's subcategoryid, from the same list the
customer app groups by. */
const aisles = useAppAisles(tenantid || undefined);
const aisleName =
(aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ??
null;
const source = useCatalogueProduct(product.productbrand, product.productsku); const source = useCatalogueProduct(product.productbrand, product.productsku);
const images = imagesOf(product); const images = imagesOf(product);
const state = stateOf(product); const state = stateOf(product);
@@ -198,10 +205,17 @@ export function ProductDrawer({
value={isPublished(product) ? 'Yes' : 'Not yet'} value={isPublished(product) ? 'Yes' : 'Not yet'}
{...(isPublished(product) ? { tone: 'success' as const } : {})} {...(isPublished(product) ? { tone: 'success' as const } : {})}
/> />
{/* The AISLE, which is what a shopper sees.
`categoryname` is empty on every row — the app's category 2 has no
row in `productcategories` — and `getlocationproducts` joins no
subcategory table, so the name is looked up from the platform list
the customer app itself groups by. "Uncategorized" is not a
cosmetic default here: it is the heading the app will print. */}
<Row <Row
label="Category" label="Shown in the app under"
value={product.categoryname || product.Subcategoryname || 'Uncategorised'} value={aisleName ?? 'Uncategorized'}
icon={<Tag size={14} />} icon={<Tag size={14} />}
{...(aisleName ? {} : { tone: 'muted' as const })}
/> />
<Row <Row
label="Unit" label="Unit"

View File

@@ -8,7 +8,7 @@ import { VStack } from '@astryxdesign/core/VStack';
import { ImageOff } from 'lucide-react'; import { ImageOff } from 'lucide-react';
import type { Product } from '@/api/types'; import type { Product } from '@/api/types';
import { SectionHeader } from '@/components/SectionHeader'; import { SectionHeader } from '@/components/SectionHeader';
import { useLocationProducts, useTenantCategories } from '@/queries/hooks'; import { useAppAisles, useLocationProducts } from '@/queries/hooks';
import { useBranchScope } from './BranchScope'; import { useBranchScope } from './BranchScope';
import { count, money } from './format'; import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer'; import { ProductDrawer } from './ProductDrawer';
@@ -71,7 +71,23 @@ export function ProductsPanel({
const products = useLocationProducts(tenantid || undefined, current?.locationid, 0, { const products = useLocationProducts(tenantid || undefined, current?.locationid, 0, {
allBranches: true, allBranches: true,
}); });
const categories = useTenantCategories(tenantid || undefined); /**
* The aisles, not the categories, because the aisle is what a shopper sees.
*
* `gettenantcategories` reports one entry for every tenant — category 2 —
* so the filter it fed was a dropdown with a single option, and the column
* beside it read "—" on every row. What actually separates products in the
* customer app is `subcategoryid`, one of ten platform rows; see
* `appAisle.ts`.
*/
const aisles = useAppAisles(tenantid || undefined);
const aisleNames = useMemo(() => {
const out = new Map<number, string>();
for (const row of aisles.data ?? []) {
if (row.subcatid > 0 && row.subcatname) out.set(row.subcatid, row.subcatname);
}
return out;
}, [aisles.data]);
/** /**
* One list, ordered by what needs doing. * One list, ordered by what needs doing.
@@ -82,7 +98,7 @@ export function ProductsPanel({
*/ */
const rows = useMemo(() => { const rows = useMemo(() => {
const filtered = (products.data ?? []).filter( const filtered = (products.data ?? []).filter(
(product) => !category || String(product.categoryid ?? '') === category, (product) => !category || String(product.subcategoryid ?? 0) === category,
); );
const rank: Record<ProductState, number> = { unpriced: 0, unstocked: 1, live: 2 }; const rank: Record<ProductState, number> = { unpriced: 0, unstocked: 1, live: 2 };
return [...filtered].sort((a, b) => { return [...filtered].sort((a, b) => {
@@ -94,13 +110,16 @@ export function ProductsPanel({
const categoryOptions = useMemo( const categoryOptions = useMemo(
() => [ () => [
{ value: '', label: 'All categories' }, { value: '', label: 'All aisles' },
...(categories.data ?? []).map((entry) => ({ // Offered whether or not anything is in it — a shop wanting to know what
value: String(entry.categoryid), // the app cannot place should not have to guess that the option exists.
label: entry.categoryname, { value: '0', label: 'Uncategorized' },
...(aisles.data ?? []).map((entry) => ({
value: String(entry.subcatid),
label: entry.subcatname,
})), })),
], ],
[categories.data], [aisles.data],
); );
return ( return (
@@ -124,6 +143,7 @@ export function ProductsPanel({
<ProductTable <ProductTable
rows={rows} rows={rows}
aisleNames={aisleNames}
queryKey={category} queryKey={category}
isLoading={products.isLoading} isLoading={products.isLoading}
onOpen={setOpen} onOpen={setOpen}
@@ -159,12 +179,15 @@ export function ProductsPanel({
function ProductTable({ function ProductTable({
rows, rows,
aisleNames,
isLoading, isLoading,
onOpen, onOpen,
onPrice, onPrice,
queryKey, queryKey,
}: { }: {
rows: Product[]; rows: Product[];
/** Subcategory id to the aisle name the customer app shows it under. */
aisleNames: ReadonlyMap<number, string>;
isLoading: boolean; isLoading: boolean;
onOpen: (product: Product) => void; onOpen: (product: Product) => void;
onPrice: (product: Product) => void; onPrice: (product: Product) => void;
@@ -251,6 +274,7 @@ function ProductTable({
<Row <Row
key={product.productid} key={product.productid}
product={product} product={product}
aisleNames={aisleNames}
onOpen={() => onOpen(product)} onOpen={() => onOpen(product)}
onPrice={() => onPrice(product)} onPrice={() => onPrice(product)}
/> />
@@ -276,10 +300,12 @@ function ProductTable({
*/ */
function Row({ function Row({
product, product,
aisleNames,
onOpen, onOpen,
onPrice, onPrice,
}: { }: {
product: Product; product: Product;
aisleNames: ReadonlyMap<number, string>;
onOpen: () => void; onOpen: () => void;
onPrice: () => void; onPrice: () => void;
}) { }) {
@@ -337,7 +363,13 @@ function Row({
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
{product.categoryname || product.Subcategoryname || '—'} {/* The aisle id is on the row; the NAME is not — `getlocationproducts`
selects `a.*` and joins no subcategory table — so it is looked up
from the platform list the app itself groups by. */}
{aisleNames.get(product.subcategoryid ?? 0) ??
product.categoryname ??
product.Subcategoryname ??
'Uncategorized'}
</span> </span>
</Td> </Td>
<Td isMuted> <Td isMuted>

View File

@@ -296,9 +296,10 @@ export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImpo
/> />
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}> <Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
Required columns: productname, productsku, categoryid, retailprice, productcost. The Required columns: productname, productsku, retailprice, productcost. A categoryid
catalogue's category names do not map to a tenant's own ids, so categoryid must be in column is not one of them and is ignored if present — the Category column below shows
the file. what each row was classified as, and that name is exchanged for your own category id
when the products are created.
</Text> </Text>
{parseError ? ( {parseError ? (

View File

@@ -0,0 +1,91 @@
/**
* The fold from the catalogue's 31 categories into the app's ten aisles.
*
* The stakes here are not cosmetic. `subcategoryid` is the only thing the
* customer app groups by, and a category that folds to nothing lands the
* product in "Uncategorized" — which is the state the whole platform was in.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
APP_AISLES,
FALLBACK_AISLE_IDS,
aisleForCategory,
aisleIdForCategory,
aisleIdsFrom,
} from './appAisle';
import { CATEGORY_REGISTRY, UNKNOWN_CATEGORY } from './productCategory';
const ids = aisleIdsFrom(undefined);
test('every one of the 31 categories folds into an aisle', () => {
const orphans = CATEGORY_REGISTRY.map((entry) => entry.category).filter(
(category) => aisleForCategory(category) === null,
);
assert.deepEqual(orphans, [], 'a category with no aisle is a product the app cannot show');
});
// "General" is the ladder saying it does not know. Inventing an aisle for it
// would put an unidentified product on a real shelf, which is worse than the
// app's own "Uncategorized" heading — that one at least tells the truth.
test('the unknown bucket deliberately has no aisle', () => {
assert.equal(aisleForCategory(UNKNOWN_CATEGORY), null);
assert.equal(aisleIdForCategory(UNKNOWN_CATEGORY, ids), 0);
});
test('a name that is not a category at all folds to nothing', () => {
assert.equal(aisleForCategory(''), null);
assert.equal(aisleForCategory(undefined), null);
assert.equal(aisleForCategory('Category 2'), null, 'the old default is not an aisle');
});
test('the things a shopper would look for land where they would look', () => {
assert.equal(aisleForCategory('Spices & Masalas'), 'Foodgrains & Pulses');
assert.equal(aisleForCategory('Dairy'), 'Dairy, Deli & Egg');
assert.equal(aisleForCategory('Hair Care'), 'Beauty & Personal Care');
assert.equal(aisleForCategory('Household Cleaning'), 'Hygiene Essentials');
assert.equal(aisleForCategory('Cooking Oils'), 'Oil & Ghee');
assert.equal(aisleForCategory('Biscuits & Cookies'), 'Bakes & Nuts');
});
// The registry writes an en-dash; a sheet typed by hand will not. Two strings
// that read the same must not reach different aisles.
test('dash and spacing variants reach the same aisle', () => {
assert.equal(aisleForCategory('Household – Agarbatti'), 'Hygiene Essentials');
assert.equal(aisleForCategory('Household - Agarbatti'), 'Hygiene Essentials');
assert.equal(aisleForCategory('household—agarbatti'), 'Hygiene Essentials');
assert.equal(aisleForCategory(' HEALTH CARE – ANTISEPTIC '), 'Hygiene Essentials');
});
/* ── Ids come from the platform's list, never from the wrong table ────────── */
// `productsubcategories` holds these ten names at 14-23; the app's utils list
// holds the same ten at 9-18. Matching on the name is what keeps a product out
// of the second set, which the grouping does not read.
test('ids are taken from the rows the API returned, by name', () => {
const live = aisleIdsFrom([
{ subcatid: 15, subcatname: 'Dairy, Deli & Egg', categoryid: 2 },
{ subcatid: 21, subcatname: 'Snacks & Drinks', categoryid: 2 },
]);
assert.equal(aisleIdForCategory('Dairy', live), 15);
assert.equal(aisleIdForCategory('Chocolates', live), 21);
});
// A name the API did not return has no id from it — but the aisle is still one
// of the ten, so the last-known id is better than 0, which means "no aisle".
test('a missing row falls back to the last known id rather than to zero', () => {
const partial = aisleIdsFrom([{ subcatid: 15, subcatname: 'Dairy, Deli & Egg', categoryid: 2 }]);
assert.equal(aisleIdForCategory('Cooking Oils', partial), FALLBACK_AISLE_IDS['Oil & Ghee']);
});
test('an empty response falls back to the whole known list', () => {
const empty = aisleIdsFrom([]);
assert.equal(empty.size, APP_AISLES.length);
assert.equal(aisleIdForCategory('Eggs', empty), FALLBACK_AISLE_IDS['Dairy, Deli & Egg']);
});
test('every aisle has an id, and no two share one', () => {
const values = APP_AISLES.map((aisle) => FALLBACK_AISLE_IDS[aisle]);
assert.equal(new Set(values).size, values.length);
assert.ok(values.every((id) => id > 0));
});

View File

@@ -0,0 +1,198 @@
/**
* The aisle the customer app actually displays, and how a category becomes one.
*
* ── What the app does ───────────────────────────────────────────────────────
*
* Its browse screen calls
*
* GET /mob/products/getproductsbysubcategory?categoryid=2&tenantid=…&locationid=…
*
* and that endpoint does two things (`services/productService.go:237`):
*
* 1. `GetProducts` filters on `a.categoryid = ?` — UNCONDITIONALLY, with the
* 2 from the query string.
* 2. It then buckets the result by `products.subcategoryid`, against the rows
* of `productsubcategories` for category 2. Anything with subcategoryid 0
* falls into a bucket literally named "Uncategorized".
*
* So the heading a shopper reads is the SUBCATEGORY, not the category — and
* `categoryid` is not a label at all, it is the filter that decides whether the
* product comes back. Measured on live tenant 1135/1166: every product returns
* `categoryid: 2, subcategoryid: 0`, so the app renders a single heading,
* "Uncategorized", holding the whole shop. That is exactly the report from the
* app side, and it is why nothing displays category-wise.
*
* TWO CONSEQUENCES, both the opposite of what looks obvious:
*
* - `categoryid` must stay 2. Writing a per-product categoryid — 91 for
* Dairy, 92 for Spices — does not label the product, it removes it: the app
* asks for 2 and the row no longer matches.
* - `subcategoryid` is what has to be written, and it can only be one of the
* ten rows `productsubcategories` holds for category 2. Those are platform
* rows shared by every tenant, not something a shop creates.
*
* ── Why the ids are looked up rather than hardcoded ─────────────────────────
*
* `productsubcategories` (subcatid 14-23) and the app's own utils list
* (`/mob/utils/getsubcategories`, ids 9-18) carry the SAME TEN NAMES under
* different ids. The grouping reads the first, so those are the ids to write —
* but two tables one offset apart is precisely the shape that gets confused, so
* the ids are read back by name from the same API the grouping uses, and
* `FALLBACK_AISLE_IDS` is used only when that call cannot be made.
*/
import type { ProductSubCategory } from '@/api/types';
/** The ten aisles, in the order the app lists them. */
export const APP_AISLES = [
'Vegetables & Fruits',
'Dairy, Deli & Egg',
'Meat, Chicken & Fish',
'Bakes & Nuts',
'Foodgrains & Pulses',
'Oil & Ghee',
'Rice & Cereals',
'Snacks & Drinks',
'Beauty & Personal Care',
'Hygiene Essentials',
] as const;
export type AppAisle = (typeof APP_AISLES)[number];
/**
* The ids as `productsubcategories` holds them today, read from live on
* 2026-09-08. Used only when the lookup cannot be made — see the header.
*/
export const FALLBACK_AISLE_IDS: Record<AppAisle, number> = {
'Vegetables & Fruits': 14,
'Dairy, Deli & Egg': 15,
'Meat, Chicken & Fish': 16,
'Bakes & Nuts': 17,
'Foodgrains & Pulses': 18,
'Oil & Ghee': 19,
'Rice & Cereals': 20,
'Snacks & Drinks': 21,
'Beauty & Personal Care': 22,
'Hygiene Essentials': 23,
};
/**
* The catalogue team's 31 categories, folded into the app's ten aisles.
*
* The 31 are a finer classification than the shop floor has room for, so this
* is a real narrowing and some of it is judgement rather than fact: "Spices &
* Masalas" has no aisle of its own and goes to Foodgrains & Pulses, the nearest
* the app offers. Where a category could sit in two it goes to the one a
* shopper would look in first — instant noodles to Snacks & Drinks rather than
* Rice & Cereals, because that is where a shop stocks them.
*
* Anything not listed — including "General", the ladder's explicit "we do not
* know" — has no aisle, and `aisleForCategory` returns null rather than
* guessing. Those products land in the app's own "Uncategorized" bucket, which
* is honest: it says the classification failed, instead of shelving bleach with
* the butter.
*/
const AISLE_FOR_CATEGORY: Record<string, AppAisle> = {
/* Bakes & Nuts */
'biscuits & cookies': 'Bakes & Nuts',
rusk: 'Bakes & Nuts',
crackers: 'Bakes & Nuts',
'cakes & muffins': 'Bakes & Nuts',
'bakery & breads': 'Bakes & Nuts',
/* Snacks & Drinks */
snacks: 'Snacks & Drinks',
chocolates: 'Snacks & Drinks',
'candy & confectionery': 'Snacks & Drinks',
beverages: 'Snacks & Drinks',
'noodles & instant food': 'Snacks & Drinks',
/* Foodgrains & Pulses */
'pulses, grains & spices': 'Foodgrains & Pulses',
'spices & masalas': 'Foodgrains & Pulses',
'salt & staples': 'Foodgrains & Pulses',
'sugar & jaggery': 'Foodgrains & Pulses',
/* Rice & Cereals */
'atta & staples': 'Rice & Cereals',
/* Oil & Ghee */
'cooking oils': 'Oil & Ghee',
/* Dairy, Deli & Egg */
dairy: 'Dairy, Deli & Egg',
eggs: 'Dairy, Deli & Egg',
/* Meat, Chicken & Fish */
'fish & seafood': 'Meat, Chicken & Fish',
/* Vegetables & Fruits */
'fruits & vegetables': 'Vegetables & Fruits',
'fresh herbs & greens': 'Vegetables & Fruits',
flowers: 'Vegetables & Fruits',
/* Beauty & Personal Care */
'oral care': 'Beauty & Personal Care',
'hair care': 'Beauty & Personal Care',
'bath soap': 'Beauty & Personal Care',
'skin & bath care': 'Beauty & Personal Care',
'fragrance & deodorants': 'Beauty & Personal Care',
/* Hygiene Essentials */
'household cleaning': 'Hygiene Essentials',
'health care - antiseptic': 'Hygiene Essentials',
'household - agarbatti': 'Hygiene Essentials',
'household - lamp oil': 'Hygiene Essentials',
};
/**
* Every dash normalised to a plain hyphen before lookup.
*
* The registry writes "Household – Agarbatti" with an en-dash and a sheet typed
* by hand will not. Two strings that read identically must not reach different
* aisles because of which key somebody pressed.
*/
function keyOf(category: string | null | undefined): string {
return (category ?? '')
.trim()
.toLowerCase()
.replace(/[‐-―]/g, '-')
.replace(/\s*-\s*/g, ' - ')
.replace(/\s+/g, ' ');
}
/** The aisle a classified category belongs in, or null when there is none. */
export function aisleForCategory(category: string | null | undefined): AppAisle | null {
return AISLE_FOR_CATEGORY[keyOf(category)] ?? null;
}
/**
* Aisle name to the id the app groups on, from the platform's own list.
*
* Matched on the NAME, because the id is exactly what differs between the two
* tables carrying these ten rows. A name the API does not return has no id, and
* the caller writes 0 rather than a number out of the wrong table.
*/
export function aisleIdsFrom(
subcategories: readonly ProductSubCategory[] | undefined,
): Map<string, number> {
const out = new Map<string, number>();
for (const row of subcategories ?? []) {
const name = (row.subcatname ?? '').trim();
if (name && row.subcatid > 0) out.set(name.toLowerCase(), row.subcatid);
}
if (out.size === 0) {
for (const [name, id] of Object.entries(FALLBACK_AISLE_IDS)) out.set(name.toLowerCase(), id);
}
return out;
}
/** The subcategory id for a classified category — 0 when it has no aisle. */
export function aisleIdForCategory(
category: string | null | undefined,
aisleIds: ReadonlyMap<string, number>,
): number {
const aisle = aisleForCategory(category);
if (!aisle) return 0;
return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle];
}

View File

@@ -13,10 +13,10 @@ import type { Product, StockRequest, StockStatementRow } from '@/api/types';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { queryKeys } from '@/queries/keys'; import { queryKeys } from '@/queries/keys';
import { import {
useAppAisles,
useLocationProducts, useLocationProducts,
useStockRequests, useStockRequests,
useStockStatement, useStockStatement,
useTenantCategories,
} from '@/queries/hooks'; } from '@/queries/hooks';
import { useBranchScope } from '@/features/store-admin/BranchScope'; import { useBranchScope } from '@/features/store-admin/BranchScope';
import { ProductDrawer } from '@/features/store-admin/ProductDrawer'; import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
@@ -66,7 +66,10 @@ export function StoreProductsPage() {
const [askingMany, setAskingMany] = useState<Product[] | null>(null); const [askingMany, setAskingMany] = useState<Product[] | null>(null);
const products = useLocationProducts(tenantid || undefined, locationid, 0); const products = useLocationProducts(tenantid || undefined, locationid, 0);
const categories = useTenantCategories(tenantid || undefined); /* The aisles the customer app groups by, not the categories — see
`appAisle.ts`. `gettenantcategories` returns one entry for every tenant,
so the filter it fed had a single option in it. */
const aisles = useAppAisles(tenantid || undefined);
const statement = useStockStatement(tenantid || undefined, locationid, { pagesize: 200 }); const statement = useStockStatement(tenantid || undefined, locationid, { pagesize: 200 });
/** /**
@@ -94,7 +97,7 @@ export function StoreProductsPage() {
const term = keyword.trim().toLowerCase(); const term = keyword.trim().toLowerCase();
return (products.data ?? []) return (products.data ?? [])
.filter((product) => isPublished(product) && effectivePrice(product) > 0) .filter((product) => isPublished(product) && effectivePrice(product) > 0)
.filter((product) => !category || String(product.categoryid ?? '') === category) .filter((product) => !category || String(product.subcategoryid ?? 0) === category)
.filter((product) => .filter((product) =>
term === '' term === ''
? true ? true
@@ -130,13 +133,14 @@ export function StoreProductsPage() {
const categoryOptions = useMemo( const categoryOptions = useMemo(
() => [ () => [
{ value: '', label: 'All categories' }, { value: '', label: 'All aisles' },
...(categories.data ?? []).map((entry) => ({ { value: '0', label: 'Uncategorized' },
value: String(entry.categoryid), ...(aisles.data ?? []).map((entry) => ({
label: entry.categoryname, value: String(entry.subcatid),
label: entry.subcatname,
})), })),
], ],
[categories.data], [aisles.data],
); );
return ( return (

View File

@@ -531,14 +531,11 @@ function ShelveAction({
const result = await shelveBatch( const result = await shelveBatch(
batch, batch,
rows, rows,
{ // No category: `shelveBatch` classifies every row itself. The receipt
tenantid: receipt.tenantid, // still carries the column an older upload wrote, and it is deliberately
locationid: receipt.locationid, // not read — one id applied to a whole sheet is what filed a shop's
// The category chosen at upload time, kept on the receipt precisely // entire range under a single aisle.
// so this step does not have to guess it later. A product filed { tenantid: receipt.tenantid, locationid: receipt.locationid },
// outside the one the app browses is invisible to shoppers.
categoryid: receipt.categoryid,
},
receipt.filename || undefined, receipt.filename || undefined,
); );
await uploadsApi.markShelved({ await uploadsApi.markShelved({

View File

@@ -665,20 +665,6 @@ main {
orange would have made one page look like a different product. orange would have made one page look like a different product.
──────────────────────────────────────────────────────────────────────────── */ ──────────────────────────────────────────────────────────────────────────── */
/* The search + rail-toggle row, lifted level with the page's tab row.
42px is the measured distance between the two rows' centres. The bottom
margin then restores the 24px the grid should keep below the tab row —
without it the pull-up drags the whole grid up with it and the first row of
cards slides under the tabs. */
@media (min-width: 900px) {
.catalogue-controls {
margin-top: -42px;
margin-bottom: 18px;
position: relative;
z-index: 1;
}
}
.catalogue-layout { .catalogue-layout {
display: grid; display: grid;
gap: 20px; gap: 20px;

View File

@@ -18,6 +18,7 @@ import { stockApi, type StockRequestQuery } from '@/api/stock';
import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants'; import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants';
import { customersApi, type CustomerQuery } from '@/api/customers'; import { customersApi, type CustomerQuery } from '@/api/customers';
import { uploadsApi } from '@/api/uploads'; import { uploadsApi } from '@/api/uploads';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { queryKeys } from './keys'; import { queryKeys } from './keys';
import { LIVE_REFETCH_MS } from './interval'; import { LIVE_REFETCH_MS } from './interval';
@@ -138,15 +139,17 @@ export function useImportedRefs(tenantid: number | undefined) {
/* ── Products ────────────────────────────────────────────────────────────── */ /* ── Products ────────────────────────────────────────────────────────────── */
/** /**
* The tenant's categories as they are actually used. * The ten aisles the customer app groups products into.
* *
* Prefer this over `products/getproductcategories`, the master table missing * `productsubcategories` for category 2 — platform rows, the same ten for
* rows for live categoryids and hardcoded to `moduleid = 2`. * every tenant, so the tenantid only satisfies the endpoint's signature. This
* is the list `getproductsbysubcategory` buckets against, which makes it the
* one the console has to name products from too; see `appAisle.ts`.
*/ */
export function useTenantCategories(tenantid: number | undefined) { export function useAppAisles(tenantid: number | undefined) {
return useQuery({ return useQuery({
queryKey: queryKeys.products.tenantCategories(tenantid ?? 0), queryKey: queryKeys.products.subCategories(tenantid ?? 0, APP_BROWSE_CATEGORY),
queryFn: () => productsApi.tenantCategories(tenantid as number), queryFn: () => productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY),
enabled: typeof tenantid === 'number' && tenantid > 0, enabled: typeof tenantid === 'number' && tenantid > 0,
...stable, ...stable,
}); });