category changes

This commit is contained in:
2026-09-08 12:03:23 +05:30
parent 1a37949bbe
commit d54fadef20
22 changed files with 776 additions and 540 deletions

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ import { VStack } from '@astryxdesign/core/VStack';
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
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 { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
@@ -34,8 +36,6 @@ export interface CatalogueBrowserProps {
tenantid: number | undefined;
/** The outlet the import is written against. Required by the backend. */
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. */
actionLabel: string;
/**
@@ -56,15 +56,6 @@ export interface CatalogueBrowserProps {
* rather than one at a time into somebody else's shop.
*/
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. */
blockedReason?: string;
}
@@ -89,35 +80,17 @@ export interface CatalogueBrowserProps {
export function CatalogueBrowser({
tenantid,
locationid,
categoryOptions,
actionLabel,
onImport,
scope,
blockedReason,
isReadOnly,
alignWithTabs,
}: CatalogueBrowserProps) {
const client = useQueryClient();
const [brand, setBrand] = useState('');
const [keyword, setKeyword] = 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 [justImported, setJustImported] = useState<Set<string>>(new Set());
const [open, setOpen] = useState<CatalogueProduct | null>(null);
@@ -224,9 +197,11 @@ export function CatalogueBrowser({
arrived.
*/
mutationFn: async (products: CatalogueProduct[]) => {
const ids = await categoryIdsFor(products);
const ids = await aisleIds();
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) => {
@@ -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.
@@ -337,65 +277,40 @@ export function CatalogueBrowser({
}).category;
}
async function categoryIdsFor(products: CatalogueProduct[]): Promise<Map<string, number>> {
const out = new Map<string, number>();
const fallback = Number(chosenCategory);
if (importInto !== null && importInto !== '') {
for (const product of products) out.set(catalogueKey(product), Number(importInto));
return out;
}
const names = [...new Set(products.map(categoryNameFor).filter(Boolean))];
let resolved: Record<string, number> = {};
if (names.length > 0) {
try {
resolved = (await productsApi.resolveCategories(tenantid as number, names)) ?? {};
} catch {
resolved = {};
}
}
for (const product of products) {
const name = 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);
/**
* The aisle ids the app groups by, read from the platform's own list.
*
* One call for a whole batch. It is `productsubcategories` for category 2 —
* ten rows, shared by every tenant — and matching on the NAME rather than
* trusting a remembered id is what keeps this off the app's other
* subcategory table, which carries the same ten names five ids lower.
*/
async function aisleIds(): Promise<Map<string, number>> {
try {
const resolved = await productsApi.resolveCategories(tenantid as number, [name]);
return resolved?.[name.toLowerCase()] ?? Number(chosenCategory);
return aisleIdsFrom(await productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY));
} 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);
return aisleIdsFrom(undefined);
}
}
function importRowFor(
product: CatalogueProduct,
categoryid: number,
subcategoryid: number,
): ImportCatalogueProductRequest {
return {
tenantid: tenantid as number,
locationid: locationid as number,
brand: product.brand,
catalogueid: product.id,
/* This used to read `Number(importInto) || 0`, defended as
"uncategorised beats wrongly categorised — an unclassified product is
visibly unfinished". It is not visible anywhere: the console lists it
like any other product and only the customer app knows it is gone.
A wrongly filed product is at least findable and fixable; one under
category 0 is returned by no query the app makes. */
categoryid,
subcategoryid: 0,
/* ALWAYS 2, and this is not a placeholder.
`getproductsbysubcategory` filters on `categoryid = 2` — the value the
app sends — so a per-product categoryid does not label the product, it
deletes it from the app's view. What the shopper actually reads as the
aisle heading is the subcategory below, which until now was 0 on every
product on the platform: one bucket, "Uncategorized", holding the shop.
See `appAisle.ts` for the endpoint this is measured against. */
categoryid: APP_BROWSE_CATEGORY,
subcategoryid,
quantity: 0,
stocktype: 'in',
status: 'Draft',
@@ -408,12 +323,13 @@ export function CatalogueBrowser({
async function importDirect(product: CatalogueProduct) {
if (!tenantid || !locationid) return;
// Guarded rather than defaulted to 0 — see `hasNoCategory`.
if (chosenCategory === '') return;
const key = catalogueKey(product);
setBusy(key);
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));
} finally {
setBusy(null);
@@ -423,17 +339,6 @@ export function CatalogueBrowser({
const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product));
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() {
setBrand('');
@@ -453,27 +358,26 @@ export function CatalogueBrowser({
<VStack gap={2}>
{scope}
{/* Pulled up onto the tab row's line and right-aligned, so search sits
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.
{/* Search, filters and the rail toggle, on their own line.
The pull-up is a CSS offset rather than the controls being rendered
inside the tab row, because the tab row belongs to the page
(`InventoryPage`) and these controls belong to the browser's own
state. Rendering them there would mean lifting `keyword` and the rail
toggle into two different pages to keep one row tidy. It reverts to
its own line below 900px, where the tabs need the width.
They used to be PULLED UP onto the page's tab row by a -42px offset
(`alignWithTabs`, `.catalogue-controls`), because that row looked
mostly empty to the right. It is not empty any more — Inventory's tab
row carries "Upload sheet" at its right end — and the offset does not
move a narrow control into a gap, it lays a FULL-WIDTH row on top of
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
narrows the whole catalogue, and the rail only lists brands. */}
<HStack
gap={1}
align="center"
wrap="wrap"
justify="end"
{...(alignWithTabs ? { className: 'catalogue-controls' } : {})}
>
<HStack gap={1} align="center" wrap="wrap" justify="end">
{activeFilters.map((filter) => (
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
))}
@@ -603,7 +507,7 @@ export function CatalogueBrowser({
variant="primary"
size="sm"
isLoading={importMany.isPending}
isDisabled={importMany.isPending || hasNoCategory}
isDisabled={importMany.isPending}
onClick={() =>
importMany.mutate(
rows.filter((product) => selection.has(product.id)),
@@ -665,10 +569,7 @@ export function CatalogueBrowser({
isImported={importedKeys.has(catalogueKey(open))}
isBusy={busy === catalogueKey(open)}
actionLabel={actionLabel}
{...(showCategoryPicker
? { categoryOptions, categoryid: pickerValue, onCategoryChange: setImportInto }
: {})}
{...(canImport && !isReadOnly && !hasNoCategory
{...(canImport && !isReadOnly
? {
onImport: () => {
const product = open;
@@ -678,15 +579,13 @@ export function CatalogueBrowser({
}
: {})}
{...(blockedReason
? // The caller's reason wins: "no outlet selected" outranks "no
// category", because you cannot pick one without the other.
? // The only reason left is the caller's — "no outlet selected".
// "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 }
: 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)}
/>
) : null}

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { Banner } from '@astryxdesign/core/Banner';
import { Lightbox } from '@astryxdesign/core/Lightbox';
import { Selector } from '@astryxdesign/core/Selector';
import { Check, DownloadCloud, ImageOff } from 'lucide-react';
import type { CatalogueProduct } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer';
@@ -14,6 +13,7 @@ import {
Section,
} from '@/features/store-admin/drawerKit';
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
import { aisleForCategory } from '@/features/store-admin/appAisle';
import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel';
/**
@@ -40,10 +40,6 @@ export interface CatalogueDetailDrawerProps {
isImported: boolean;
isBusy?: boolean;
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. */
onImport?: () => void;
/** Shown in place of the action when importing is unavailable. */
@@ -56,14 +52,16 @@ export function CatalogueDetailDrawer({
isImported,
isBusy,
actionLabel = 'Add to my products',
categoryOptions,
categoryid = '',
onCategoryChange,
onImport,
blockedReason,
onClose,
}: 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({
catalogueCategory: product.category,
title: product.product_name,
@@ -71,6 +69,9 @@ export function CatalogueDetailDrawer({
packSize: product.size ?? '',
});
/* The app groups by subcategory, not category — see `appAisle.ts`. */
const aisle = aisleForCategory(suggested.category);
const images = product.images ?? [];
const [heroAt, setHeroAt] = useState(0);
const [isZoomed, setIsZoomed] = useState(false);
@@ -255,7 +256,7 @@ export function CatalogueDetailDrawer({
{/* 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
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 ? (
<Banner
status="success"
@@ -268,36 +269,37 @@ export function CatalogueDetailDrawer({
) : onImport ? (
<Section title="Import into my products">
<DrawerCard>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 14 }}>
{categoryOptions ? (
<Selector
label="Category"
size="sm"
options={categoryOptions}
value={categoryid}
onChange={(value) => onCategoryChange?.(value)}
/* No "leave uncategorised" placeholder, and the advice that
used to sit here — "uncategorised beats wrongly categorised"
— is gone because it is false. A product filed under
category 0 is returned by NO query the customer app makes:
its endpoint rejects categoryid 0 outright. Seven products
across three tenants sat invisible that way, six outlets
showing shoppers an empty shop, while the console listed
their stock as normal. A wrongly filed product is at least
findable and fixable; an unfiled one was neither. */
placeholder={`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}
<Row
label="Filed under"
value={<Badge label={suggested.category} colour="var(--color-brand)" />}
/>
{/* The heading a shopper actually reads. The catalogue's 31
categories are finer than the app's ten aisles, so the two are
shown side by side rather than one standing in for the other —
and a product with no aisle is told so here, not discovered
missing from the app later. */}
<Row
label="Shown in the app under"
value={
aisle ? (
<Badge label={aisle} colour="#0f8a5f" />
) : (
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
)
}
/>
<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)' }}>
Adds this product with no price. It reaches no shop and cannot be sold until you
price and publish it.
{suggested.rule === 'catalogue'
? `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>
</div>
</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.
*
@@ -10,46 +8,18 @@ import type { ProductCategory } from '@/api/types';
* 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
* 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 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.
*
* A sheet becomes stock on one shelf, so the upload has to name a tenant, a
* branch and a category before it can do anything — and where those come from
* depends entirely on who is signed in:
* A sheet becomes stock on one shelf, so the upload has to name a tenant and a
* branch before it can do anything — and where those come from depends entirely
* on who is signed in:
*
* - A **Nearle Admin** works across every merchant. Nothing in their session
* 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
* held per outlet, so a sheet uploaded against the wrong branch loads a shelf
* 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 { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { useTenantCategories, useTenantLocations, useTenants } from '@/queries/hooks';
import { categoryOptionsFor } from '@/features/catalogue/tenantCategories';
import { useTenantLocations, useTenants } from '@/queries/hooks';
/** Everything an upload needs before it can be turned into stock. */
export interface ImportTarget {
tenantid: number;
locationid: number;
/** Used for any sheet row that names no category of its own. */
categoryid: number;
}
export interface ImportScopeProps {
@@ -48,7 +54,6 @@ export interface ImportScopeProps {
export function ImportScope({ value, onChange, isTenantFixed = false }: ImportScopeProps) {
const tenants = useTenants({ pageno: 1, pagesize: 200 });
const locations = useTenantLocations(value.tenantid);
const categories = useTenantCategories(value.tenantid);
const tenantOptions = useMemo(
() =>
@@ -70,39 +75,11 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
[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.
*
* 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,
* where `gettenantcategories` reports one usable value for every tenant seen
* so far.
* behind a dropdown with one entry.
*/
useEffect(() => {
if (!value.tenantid) return;
@@ -111,13 +88,6 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
}
}, [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 (
<VStack gap={1.5}>
{!isTenantFixed ? (
@@ -129,9 +99,9 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
placeholder={tenants.isLoading ? 'Loading merchants…' : 'Choose a merchant'}
description="Whose catalogue this sheet is loading. Nothing is uploaded until this is set."
onChange={(next) =>
// Changing the merchant clears the branch and category with it.
// Keeping them would leave another tenant's branch id attached to
// this one — an id that is valid-looking and wrong.
// Changing the merchant clears the branch with it. Keeping it
// would leave another tenant's branch id attached to this one — an
// id that is valid-looking and wrong.
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 })}
/>
<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 ? (
<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.
</Text>
) : 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>
);
}
/** True when every field an import needs has been answered. */
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. */
@@ -195,7 +143,6 @@ export function describeMissingTarget(value: Partial<ImportTarget>): string | nu
const missing = [
!value.tenantid ? 'a merchant' : null,
!value.locationid ? 'a branch' : null,
!value.categoryid ? 'a category' : null,
].filter(Boolean);
if (missing.length === 0) return null;
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> {
productname: string;
productsku: string;
categoryid: number;
category: string;
retailprice: number;
productcost: number;
quantity: number;
@@ -211,7 +211,9 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
await uploadsApi.record({
tenantid: target.tenantid,
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,
filename: file.name,
sender: submitted.submitted_by ?? '',
@@ -315,9 +317,20 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
</VStack>
),
},
/* No Category column. The console no longer reads one from the sheet — it
is chosen once above and applied to every row — so a column here would
show 0 on every line and invite somebody to "fix" it in the file. */
/* The category, shown because nobody chooses it any more.
It is worked out per row before the upload, so this column is the one
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',
header: 'Retail',
@@ -355,7 +368,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
const preview: PreviewRow[] = (parsed?.rows ?? []).map((row: SheetProductRow) => ({
productname: row.productname,
productsku: row.productsku,
categoryid: row.categoryid,
category: row.category ?? '',
retailprice: row.retailprice,
productcost: row.productcost,
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
exact rather than by name.
<br />
You do not need a categoryid column — the category chosen above applies to every row
that does not name one. Leaving it out is the safer shape: a wrong id in a sheet is
accepted silently and hides the product from shoppers, while the picker can only offer
ids the customer app actually browses.
You are not asked for a category, and a categoryid column is ignored if you include
one. Each product is filed by the platform's own classification — the sheet's category
column if it has one, then the product registry, then its name — into one of the 31
categories the customer app browses, so a mixed sheet no longer lands in a single aisle.
</Text>
{parseError ? (

View File

@@ -96,7 +96,7 @@ test('one call carries the price and the opening stock together', () => {
const { requests } = buildImportRequests(plan, {
tenantid: 1141,
locationid: 1179,
fallbackCategoryId: 2,
aisleIds: new Map(),
catalogueIds: ids,
});
@@ -110,17 +110,19 @@ test('one call carries the price and the opening stock together', () => {
assert.equal(req.locationid, 1179);
});
// categoryid 0 is the state the customer app rejects outright — it has cost this
// system seven invisible products already.
test('a blank sheet category falls back to the operator choice, never zero', () => {
const plan = planOpeningStock([manifest()], [sheetRow({ categoryid: 0 })]);
// categoryid 2 is not a label, it is the value the app FILTERS on — see
// `appAisle.ts`. A per-product categoryid does not categorise the product, it
// removes it from the app's browse entirely.
test('categoryid is always the one the app asks for', () => {
const plan = planOpeningStock([manifest()], [sheetRow({ category: 'Dairy', categoryid: 91 })]);
const { requests } = buildImportRequests(plan, {
tenantid: 1141,
locationid: 1179,
fallbackCategoryId: 2,
aisleIds: new Map([['dairy, deli & egg', 15]]),
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.
@@ -129,7 +131,7 @@ test('an unpriced row is refused rather than listed at zero', () => {
const out = buildImportRequests(plan, {
tenantid: 1141,
locationid: 1179,
fallbackCategoryId: 2,
aisleIds: new Map(),
catalogueIds: ids,
});
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, {
tenantid: 1141,
locationid: 1179,
fallbackCategoryId: 2,
aisleIds: new Map(),
catalogueIds: new Map(),
});
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', () => {
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');
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);
});
/* ── A sheet with no categoryid column at all ─────────────────────────────── */
// 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 —
// otherwise dropping the column would silently hide every product uploaded.
test('a sheet with no category column gets the operator choice on every row', () => {
// customer app rejects outright. It must never reach the request whatever the
// sheet holds — otherwise dropping the column would hide every product uploaded.
test('a sheet with nothing to classify still never files a row under zero', () => {
const rows = [
sheetRow({ productname: 'Amul Butter 100g', productsku: 'A1', 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, {
tenantid: 1141,
locationid: 1179,
fallbackCategoryId: 2,
aisleIds: new Map(),
catalogueIds: new Map([
['amul_amul_butter_100g', 42],
['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);
for (const req of requests) {
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
// mapping the column, so every row arrives as 0 and the operator answer is the
// only source. This is the guard on that: if a row ever carries a stray value
// it is still used, but nothing in the parser produces one any more.
test('the operator answer applies even when a row somehow carries zero', () => {
const plan = planOpeningStock([manifest()], [sheetRow({ categoryid: 0 })]);
// A stray subcategoryid in a sheet is honoured only when nothing classified the
// row — the classification always wins, because a number typed into a column
// nobody documents is the least trustworthy thing in the file.
test('the classification beats a subcategoryid typed into the sheet', () => {
const plan = planOpeningStock(
[manifest()],
[sheetRow({ category: 'Dairy', subcategoryid: 99 })],
);
const { requests } = buildImportRequests(plan, {
tenantid: 1141,
locationid: 1179,
fallbackCategoryId: 2,
aisleIds: new Map([['dairy, deli & egg', 15]]),
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 { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { aisleIdForCategory } from '@/features/store-admin/appAisle';
import type { SheetProductRow } from '@/api/products';
/** Where a product's price and opening stock came from. */
@@ -148,8 +150,14 @@ export interface ImportRequestRow {
export interface BuildOptions {
tenantid: 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. */
catalogueIds: ReadonlyMap<string, number>;
}
@@ -178,7 +186,7 @@ export interface BuildResult {
* sent and both come back named.
*/
export function buildImportRequests(plan: StockPlan, options: BuildOptions): BuildResult {
const { tenantid, locationid, fallbackCategoryId, catalogueIds } = options;
const { tenantid, locationid, aisleIds, catalogueIds } = options;
const requests: ImportRequestRow[] = [];
const unresolved: IngestProduct[] = [];
@@ -202,11 +210,16 @@ export function buildImportRequests(plan: StockPlan, options: BuildOptions): Bui
locationid,
brand: product.brand,
catalogueid,
// The sheet's own category when it named one, the operator's choice
// otherwise. Never 0 — a product filed under 0 is invisible to shoppers
// and no API could repair it until recently.
categoryid: Number(row.categoryid) || fallbackCategoryId,
subcategoryid: Number(row.subcategoryid) || 0,
// ALWAYS 2. `getproductsbysubcategory` filters on `categoryid = 2`, the
// value the app sends, so a per-product categoryid removes the product
// from the app rather than labelling it. Never 0 either — that is the
// state the browse endpoint rejects outright.
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 product, puts it on this outlet's shelf, and records the receipt.
quantity: Math.max(0, Math.trunc(Number(row.quantity) || 0)),

View File

@@ -18,13 +18,13 @@
import { catalogueApi } from '@/api/catalogue';
import { productsApi, type SheetProductRow } from '@/api/products';
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';
export interface ShelveTarget {
tenantid: number;
locationid: number;
/** Applied to every row; no sheet column carries one any more. */
categoryid: number;
}
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, {
tenantid: target.tenantid,
locationid: target.locationid,
fallbackCategoryId: target.categoryid,
aisleIds,
catalogueIds,
});

View File

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

View File

@@ -1,8 +1,5 @@
import { useMemo } from 'react';
import { useTenantCategories } from '@/queries/hooks';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
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.
@@ -26,35 +23,11 @@ export function CataloguePanel() {
*/
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 (
<CatalogueBrowser
tenantid={tenantid || undefined}
locationid={locationid}
categoryOptions={categoryOptions}
actionLabel="Add to my products"
alignWithTabs
{...(!tenantid || !locationid
? { 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 type { Product } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { useCatalogueProduct } from '@/queries/hooks';
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { Drawer } from './Drawer';
import {
@@ -67,6 +67,13 @@ export function ProductDrawer({
const [confirmHide, setConfirmHide] = useState(false);
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 images = imagesOf(product);
const state = stateOf(product);
@@ -198,10 +205,17 @@ export function ProductDrawer({
value={isPublished(product) ? 'Yes' : 'Not yet'}
{...(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
label="Category"
value={product.categoryname || product.Subcategoryname || 'Uncategorised'}
label="Shown in the app under"
value={aisleName ?? 'Uncategorized'}
icon={<Tag size={14} />}
{...(aisleName ? {} : { tone: 'muted' as const })}
/>
<Row
label="Unit"

View File

@@ -8,7 +8,7 @@ import { VStack } from '@astryxdesign/core/VStack';
import { ImageOff } from 'lucide-react';
import type { Product } from '@/api/types';
import { SectionHeader } from '@/components/SectionHeader';
import { useLocationProducts, useTenantCategories } from '@/queries/hooks';
import { useAppAisles, useLocationProducts } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer';
@@ -71,7 +71,23 @@ export function ProductsPanel({
const products = useLocationProducts(tenantid || undefined, current?.locationid, 0, {
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.
@@ -82,7 +98,7 @@ export function ProductsPanel({
*/
const rows = useMemo(() => {
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 };
return [...filtered].sort((a, b) => {
@@ -94,13 +110,16 @@ export function ProductsPanel({
const categoryOptions = useMemo(
() => [
{ value: '', label: 'All categories' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
{ value: '', label: 'All aisles' },
// Offered whether or not anything is in it — a shop wanting to know what
// the app cannot place should not have to guess that the option exists.
{ value: '0', label: 'Uncategorized' },
...(aisles.data ?? []).map((entry) => ({
value: String(entry.subcatid),
label: entry.subcatname,
})),
],
[categories.data],
[aisles.data],
);
return (
@@ -124,6 +143,7 @@ export function ProductsPanel({
<ProductTable
rows={rows}
aisleNames={aisleNames}
queryKey={category}
isLoading={products.isLoading}
onOpen={setOpen}
@@ -159,12 +179,15 @@ export function ProductsPanel({
function ProductTable({
rows,
aisleNames,
isLoading,
onOpen,
onPrice,
queryKey,
}: {
rows: Product[];
/** Subcategory id to the aisle name the customer app shows it under. */
aisleNames: ReadonlyMap<number, string>;
isLoading: boolean;
onOpen: (product: Product) => void;
onPrice: (product: Product) => void;
@@ -251,6 +274,7 @@ function ProductTable({
<Row
key={product.productid}
product={product}
aisleNames={aisleNames}
onOpen={() => onOpen(product)}
onPrice={() => onPrice(product)}
/>
@@ -276,10 +300,12 @@ function ProductTable({
*/
function Row({
product,
aisleNames,
onOpen,
onPrice,
}: {
product: Product;
aisleNames: ReadonlyMap<number, string>;
onOpen: () => void;
onPrice: () => void;
}) {
@@ -337,7 +363,13 @@ function Row({
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>
</Td>
<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)' }}>
Required columns: productname, productsku, categoryid, retailprice, productcost. The
catalogue's category names do not map to a tenant's own ids, so categoryid must be in
the file.
Required columns: productname, productsku, retailprice, productcost. A categoryid
column is not one of them and is ignored if present — the Category column below shows
what each row was classified as, and that name is exchanged for your own category id
when the products are created.
</Text>
{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 { queryKeys } from '@/queries/keys';
import {
useAppAisles,
useLocationProducts,
useStockRequests,
useStockStatement,
useTenantCategories,
} from '@/queries/hooks';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
@@ -66,7 +66,10 @@ export function StoreProductsPage() {
const [askingMany, setAskingMany] = useState<Product[] | null>(null);
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 });
/**
@@ -94,7 +97,7 @@ export function StoreProductsPage() {
const term = keyword.trim().toLowerCase();
return (products.data ?? [])
.filter((product) => isPublished(product) && effectivePrice(product) > 0)
.filter((product) => !category || String(product.categoryid ?? '') === category)
.filter((product) => !category || String(product.subcategoryid ?? 0) === category)
.filter((product) =>
term === ''
? true
@@ -130,13 +133,14 @@ export function StoreProductsPage() {
const categoryOptions = useMemo(
() => [
{ value: '', label: 'All categories' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
{ value: '', label: 'All aisles' },
{ value: '0', label: 'Uncategorized' },
...(aisles.data ?? []).map((entry) => ({
value: String(entry.subcatid),
label: entry.subcatname,
})),
],
[categories.data],
[aisles.data],
);
return (

View File

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

View File

@@ -665,20 +665,6 @@ main {
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 {
display: grid;
gap: 20px;

View File

@@ -18,6 +18,7 @@ import { stockApi, type StockRequestQuery } from '@/api/stock';
import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants';
import { customersApi, type CustomerQuery } from '@/api/customers';
import { uploadsApi } from '@/api/uploads';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { queryKeys } from './keys';
import { LIVE_REFETCH_MS } from './interval';
@@ -138,15 +139,17 @@ export function useImportedRefs(tenantid: number | undefined) {
/* ── 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
* rows for live categoryids and hardcoded to `moduleid = 2`.
* `productsubcategories` for category 2 — platform rows, the same ten for
* 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({
queryKey: queryKeys.products.tenantCategories(tenantid ?? 0),
queryFn: () => productsApi.tenantCategories(tenantid as number),
queryKey: queryKeys.products.subCategories(tenantid ?? 0, APP_BROWSE_CATEGORY),
queryFn: () => productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY),
enabled: typeof tenantid === 'number' && tenantid > 0,
...stable,
});