This commit is contained in:
2026-09-07 17:27:27 +05:30
parent c31696ce41
commit 954dfb8c00
6 changed files with 671 additions and 24 deletions

View File

@@ -11,21 +11,40 @@ import { parseProductSheet } from './parseProductSheet';
const sheet = (csv: string) => new File([csv], 'products.csv', { type: 'text/csv' });
// The template ships a `Category` column holding a NAME, for the ingest service.
// The template ships a `Category` column holding a NAME.
//
// It used to be aliased onto `categoryid`, run through toNumber, and become 0 —
// silently, and while counting as a mapped column so it never showed up as
// ignored either.
test('a text Category column is not read as a category id', async () => {
// ignored either. The column is read again now, but as a NAME feeding rule 1 of
// the category ladder; the id it eventually gets is resolved per tenant at
// import time. The guarantee that matters is unchanged and asserted below: a
// text category never becomes a categoryid.
test('a text Category column is read as a name, never as an id', async () => {
const parsed = await parseProductSheet(
sheet('Product Name,Brand,Category,MRP\nBritannia Marie Gold 250g,Britannia,Biscuits,30\n'),
);
assert.equal(parsed.rows.length, 1);
assert.equal(parsed.rows[0]?.categoryid, 0, 'the console supplies the category, not the sheet');
assert.ok(
parsed.unmappedColumns.some((column) => /category/i.test(column)),
`Category should be reported as a column we do not read, got ${JSON.stringify(parsed.unmappedColumns)}`,
assert.equal(parsed.rows[0]?.categoryid, 0, 'the id is resolved at import, not read from the sheet');
assert.equal(parsed.rows[0]?.category, 'Biscuits', 'the sheet is authoritative for the name');
});
test('a sheet with no Category column still gets one from the ladder', async () => {
// Rule 2: the keyword registry, over the product name.
const parsed = await parseProductSheet(
sheet('Product Name,MRP\nAmul Gold Full Cream Milk 1L,60\n'),
);
assert.equal(parsed.rows[0]?.category, 'Dairy');
assert.equal(parsed.rows[0]?.categoryid, 0);
});
test('a Category column holding a number is treated as an id, not a name', async () => {
// The ladder's all-digits guard, reached through the parser: an operator who
// types 1001 in the Category column gets the ladder's answer, not "1001".
const parsed = await parseProductSheet(
sheet('Product Name,Category,MRP\nAmul Gold Full Cream Milk 1L,1001,60\n'),
);
assert.equal(parsed.rows[0]?.category, 'Dairy');
});
// Even a literal categoryid column is ignored now: a number nobody can verify

View File

@@ -7,6 +7,7 @@
*/
import type { SheetProductRow } from '@/api/products';
import { resolveCategory } from '@/features/store-admin/productCategory';
/**
* `xlsx` is around 400kB and only the spreadsheet path ever touches it, so it is
@@ -40,6 +41,20 @@ const COLUMN_ALIASES: Record<keyof SheetProductRow, string[]> = {
* service, which reads it as a name; it was never ours.
*/
categoryid: [],
/**
* The sheet's own category NAME — rule 1 of the ladder.
*
* This alias was removed once, for a good reason: it pointed at
* `categoryid`, so a text cell reading "Biscuits" was parsed as a number,
* failed, and became 0 — which hides a product from shoppers entirely,
* because the customer app rejects categoryid 0.
*
* It is safe now because it no longer feeds an id. It feeds a NAME, which
* `resolveCategory` treats as authoritative and `resolvecategories`
* exchanges for a real per-tenant id at import time. The all-digits guard in
* the ladder catches an operator who still types a number here.
*/
category: ['category', 'categoryname', 'aisle', 'department', 'section'],
subcategoryid: [],
retailprice: ['retailprice', 'price', 'mrp', 'sellingprice'],
productcost: ['productcost', 'cost', 'purchaseprice', 'costprice'],
@@ -140,12 +155,24 @@ export async function parseProductSheet(file: File): Promise<ParsedSheet> {
const productcost = toNumber(picked.productcost);
const taxpercent = toNumber(picked.taxpercent) ?? 0;
const quantity = toNumber(picked.quantity) ?? 0;
// Always zero — see COLUMN_ALIASES. The category is the operator's answer
// for the whole upload, filled in by `buildImportRequests`, not a per-row
// number read from the file.
// Still zero. The id is per-tenant and cannot be known from a file — it is
// resolved from the NAME below, once, at import time.
const categoryid = 0;
const subcategoryid = 0;
/*
The category, by the same deterministic ladder the catalogue team runs:
the sheet's own value, then the keyword registry, then the commodity
lexicon, then "General". Worked out here rather than in the preview so
the row an operator reviews and the row that is imported cannot disagree.
*/
const category = resolveCategory({
title: productname,
description: String(picked.productdesc ?? '').trim(),
sheetValue: String(picked.category ?? '').trim(),
packSize: [picked.unitvalue, picked.productunit].filter(Boolean).join(' '),
}).category;
// Only the product name is required, and that is the ingest service's rule
// rather than ours: everything else is optional and enriched when blank,
// and even the brand is inferred from the name.
@@ -190,6 +217,7 @@ export async function parseProductSheet(file: File): Promise<ParsedSheet> {
unitvalue: String(picked.unitvalue ?? '').trim() || undefined,
productbrand: String(picked.productbrand ?? '').trim() || undefined,
productdesc: String(picked.productdesc ?? '').trim() || undefined,
category,
});
});