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,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
/>