category changes
This commit is contained in:
@@ -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.`;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ export function GlobalCataloguePage() {
|
||||
<CatalogueBrowser
|
||||
tenantid={undefined}
|
||||
locationid={undefined}
|
||||
categoryOptions={[]}
|
||||
actionLabel="Add to store"
|
||||
isReadOnly
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user