upload sheet according to the tenant

This commit is contained in:
2026-08-29 12:08:38 +05:30
parent 32c3bef3ad
commit 29613e25ee
8 changed files with 1227 additions and 75 deletions

View File

@@ -0,0 +1,180 @@
/**
* 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 **Nearle Admin** works across every merchant. Nothing in their session
* says which one, so they are ASKED, and the upload stays blocked until
* they answer. Guessing here would put another merchant's products on a
* shop's shelf.
* - A **Store Admin or Store user** is already scoped by their login. Asking
* them to pick their own tenant would be offering a choice with one legal
* answer, and a chance to get it wrong.
*
* 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.
*/
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';
/** 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 {
value: Partial<ImportTarget>;
onChange: (next: Partial<ImportTarget>) => void;
/**
* True for a store login, whose tenant is fixed by the session.
*
* The tenant picker is then not merely disabled but absent: a control that
* cannot be used is still a question, and this one has no question in it.
*/
isTenantFixed?: boolean;
}
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(
() =>
(tenants.data ?? [])
.filter((tenant) => tenant.tenantid)
.map((tenant) => ({
value: String(tenant.tenantid),
label: tenant.tenantname || `Tenant ${tenant.tenantid}`,
})),
[tenants.data],
);
const branchOptions = useMemo(
() =>
(locations.data ?? []).map((branch) => ({
value: String(branch.locationid),
label: branch.locationname || `Branch ${branch.locationid}`,
})),
[locations.data],
);
const categoryOptions = useMemo(
() =>
(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
})),
[categories.data],
);
/**
* 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.
*/
useEffect(() => {
if (!value.tenantid) return;
if (!value.locationid && branchOptions.length === 1) {
onChange({ ...value, locationid: Number(branchOptions[0]!.value) });
}
}, [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 ? (
<Selector
label="Merchant"
size="sm"
options={tenantOptions}
value={value.tenantid ? String(value.tenantid) : ''}
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.
onChange({ tenantid: Number(next) || undefined })
}
/>
) : null}
<Selector
label="Branch"
size="sm"
options={branchOptions}
value={value.locationid ? String(value.locationid) : ''}
placeholder={
!value.tenantid
? 'Choose a merchant first'
: locations.isLoading
? 'Loading branches…'
: 'Choose a branch'
}
description="Where the opening stock lands. Stock is held per branch, so this decides which shelf fills."
isDisabled={!value.tenantid}
onChange={(next) => onChange({ ...value, locationid: Number(next) || undefined })}
/>
<Selector
label="Category for rows that do not name one"
size="sm"
options={categoryOptions}
value={value.categoryid ? String(value.categoryid) : ''}
placeholder={!value.tenantid ? 'Choose a merchant first' : 'Choose a category'}
description="A product with no category cannot appear in the customer app at all."
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 && categoryOptions.length === 0 && !categories.isLoading ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
This merchant has no categories yet. Products imported without one are invisible in the
customer app, so add a category before uploading.
</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);
}
/** What is still missing, in words, for a blocked-reason line. */
export function describeMissingTarget(value: Partial<ImportTarget>): string | null {
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

@@ -7,19 +7,31 @@ import { ProgressBar } from '@astryxdesign/core/ProgressBar';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-react';
import { AlertTriangle, CheckCircle2, Clock, Download, FileSpreadsheet } from 'lucide-react';
import type { SheetProductRow } from '@/api/products';
import {
ACCEPTED_EXTENSIONS,
isAwaitingReview,
isDismissed,
isIncomplete,
isSettled,
pollBatch,
productsOf,
progressOf,
submitBatch,
summarise,
type IngestBatch,
} from '@/api/ingest';
import { errorMessage } from '@/api/client';
import { catalogueApi } from '@/api/catalogue';
import { productsApi } from '@/api/products';
import {
ImportScope,
describeMissingTarget,
isTargetComplete,
type ImportTarget,
} from './ImportScope';
import { buildImportRequests, planOpeningStock, type StockPlan } from './openingStock';
import { SectionHeader } from '@/components/SectionHeader';
import { SheetDropzone } from '@/components/SheetDropzone';
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
@@ -49,7 +61,27 @@ interface PreviewRow extends Record<string, unknown> {
* twice, so that is said out loud before the button rather than discovered
* afterwards.
*/
export function SheetImportPanel() {
export interface SheetImportPanelProps {
/**
* Fixed by the session for a store login; absent for a Nearle Admin, who is
* asked instead.
*
* The ingest itself takes no tenant — it writes the shared global catalogue —
* but the step AFTER it does. Pricing each product and putting its opening
* stock on a shelf is per tenant and per branch, and a sheet applied to the
* wrong branch loads a shelf nobody is standing at.
*/
tenantid?: number;
locationid?: number;
}
export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps = {}) {
const isTenantFixed = Boolean(tenantid);
const [target, setTarget] = useState<Partial<ImportTarget>>({ tenantid, locationid });
/** The shelving step, after the run finishes. */
const [plan, setPlan] = useState<StockPlan | null>(null);
const [shelving, setShelving] = useState<string | null>(null);
const [shelved, setShelved] = useState<{ count: number; skipped: number } | null>(null);
const [file, setFile] = useState<File | null>(null);
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
@@ -114,6 +146,55 @@ export function SheetImportPanel() {
}
}
/**
* Puts the run's products on the shelf, with the sheet's own price and
* opening stock.
*
* This is the half the ingest service cannot do. It writes the GLOBAL
* catalogue, which is shared by every merchant and therefore holds no price
* and no stock; the sheet carries both, and they have travelled no further
* than this browser. Joining them is what turns "the products exist" into
* "the shop can sell them".
*
* One `importcatalogueproduct` call per batch, not per product: it takes an
* array, and each element writes the product row, the outlet row and the
* stock ledger entry together.
*/
async function handleShelve() {
if (!batch || !parsed || !isTargetComplete(target)) return;
setShelving(null);
setIsWorking(true);
try {
const products = productsOf(batch);
const nextPlan = planOpeningStock(products, parsed.rows);
setPlan(nextPlan);
// One catalogue read per BRAND rather than per product. A 500-row sheet
// would otherwise open 500 requests from a shop's browser.
const brands = [...new Set(nextPlan.matched.map((entry) => entry.product.brand))];
const catalogueIds = new Map<string, number>();
for (const brand of brands) {
for (const [imageId, id] of await catalogueApi.idsByImageId(brand)) {
catalogueIds.set(imageId, id);
}
}
const { requests, unresolved, unpriced } = buildImportRequests(nextPlan, {
tenantid: target.tenantid,
locationid: target.locationid,
fallbackCategoryId: target.categoryid,
catalogueIds,
});
if (requests.length > 0) await productsApi.importFromCatalogue(requests);
setShelved({ count: requests.length, skipped: unresolved.length + unpriced.length });
} catch (cause) {
setShelving(errorMessage(cause));
} finally {
setIsWorking(false);
}
}
const previewColumns: TableColumn<PreviewRow>[] = [
{
key: 'productname',
@@ -179,7 +260,7 @@ export function SheetImportPanel() {
/* ── Finished ─────────────────────────────────────────────────────────── */
if (batch && isSettled(batch)) {
if (batch && (isSettled(batch) || isAwaitingReview(batch) || isDismissed(batch))) {
const { totals } = batch;
const broken = isIncomplete(batch);
/* Every file the service refused, with the reason it gave for each. A file
@@ -191,7 +272,9 @@ export function SheetImportPanel() {
<Card padding={4} variant="transparent">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
{batch.status === 'failed' ? (
{isAwaitingReview(batch) ? (
<Clock size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
) : batch.status === 'failed' ? (
<AlertTriangle size={22} style={{ color: 'var(--color-error, #d64545)' }} />
) : broken ? (
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
@@ -207,7 +290,16 @@ export function SheetImportPanel() {
Batch {batch.batch_id} · {batch.status}
</Text>
{totals ? (
{isAwaitingReview(batch) ? (
/* No counts while it waits. Every total is zero because nothing has
run yet, and showing them reads as an import that found nothing
— the precise misreading this whole status exists to prevent. */
<Text type="body" color="secondary">
The file reached the service and is queued for review. Nothing is in the catalogue
yet. Keep the batch id above — it is how this upload is found once an admin releases
it.
</Text>
) : totals ? (
<Text type="body" color="secondary">
{totals.rows_total} sheet row{totals.rows_total === 1 ? '' : 's'} became{' '}
{totals.products_built} product{totals.products_built === 1 ? '' : 's'}
@@ -296,15 +388,100 @@ export function SheetImportPanel() {
</Text>
) : null}
{/* The catalogue is not the shelf. Said here because it is the single
most likely thing to be misread: the products exist now, and they
are still not on sale anywhere. */}
{batch.status !== 'failed' ? (
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
These are in the global catalogue. To put one on a shop&rsquo;s shelf, open the
catalogue, add it to that outlet with a category and a price, then receive stock
against it — a product with no stock is not offered in the customer app.
</Text>
{/* Putting them on the shelf.
The run wrote the GLOBAL catalogue, which is shared by every
merchant and holds no price and no stock. The sheet carries both
and has gone no further than this browser, so this step is the only
place the two can meet. Until it runs, the products exist and no
shop can sell them. */}
{batch.status !== 'failed' && !isAwaitingReview(batch) && !isDismissed(batch) ? (
<VStack gap={1.5}>
{shelved ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{shelved.count} product{shelved.count === 1 ? '' : 's'} priced and stocked
</Text>
<Text type="body" size="sm" color="secondary">
They are in this merchant&rsquo;s catalogue, on the chosen branch&rsquo;s shelf,
with their opening stock recorded — so they are on sale in the app now.
{shelved.skipped > 0
? ` ${shelved.skipped} were left out; see below for which and why.`
: ''}
</Text>
</VStack>
) : (
<>
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
These are in the global catalogue, which every merchant shares — so they carry
no price and no stock yet. This step adds them to the chosen branch with the
price and opening stock from your sheet.
</Text>
<HStack>
<Button
label={isWorking ? 'Working…' : 'Add to this branch with opening stock'}
variant="primary"
isLoading={isWorking}
isDisabled={isWorking || !isTargetComplete(target) || !parsed}
onClick={handleShelve}
/>
</HStack>
{describeMissingTarget(target) ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
{describeMissingTarget(target)}
</Text>
) : null}
</>
)}
{shelving ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{shelving}
</Text>
) : null}
{/* What was left out, and why. Each of these is a product the
merchant expects to have and will not, so none of them is
allowed to be silent. */}
{plan && plan.unmatched.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{plan.unmatched.length} product{plan.unmatched.length === 1 ? '' : 's'} the
sheet does not price
</Text>
<Text type="body" size="xsm" color="secondary">
A cell listing several pack sizes becomes one product each, so the run can
produce more products than the sheet has rows. These reached the global
catalogue with no price and no stock:{' '}
{plan.unmatched.slice(0, 8).map((entry) => entry.product_name).join(', ')}
{plan.unmatched.length > 8 ? ` and ${plan.unmatched.length - 8} more` : ''}.
</Text>
</VStack>
) : null}
{plan && plan.missing.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{plan.missing.length} sheet row{plan.missing.length === 1 ? '' : 's'} produced
no product
</Text>
<Text type="body" size="xsm" color="secondary">
{plan.missing.slice(0, 8).map((row) => row.productname).join(', ')}
{plan.missing.length > 8 ? ` and ${plan.missing.length - 8} more` : ''} — the
run rejected or never saw these.
</Text>
</VStack>
) : null}
{plan && plan.matched.some((entry) => entry.basis === 'name') ? (
<Text type="body" size="xsm" color="secondary">
{plan.matched.filter((entry) => entry.basis === 'name').length} product
{plan.matched.filter((entry) => entry.basis === 'name').length === 1 ? '' : 's'}{' '}
were matched on their name rather than a SKU, because those sheet rows left the
SKU blank. Add a SKU column to make the match exact.
</Text>
) : null}
</VStack>
) : null}
<RawResponse payload={batch} />
@@ -342,6 +519,11 @@ export function SheetImportPanel() {
}
/>
{/* Who this sheet is for.
A Nearle Admin is asked; a store login already answered by signing
in, so it renders nothing for them. */}
<ImportScope value={target} onChange={setTarget} isTenantFixed={isTenantFixed} />
{/* The service reads .tsv as well, and the dropzone's own default
list did not offer it — a file the picker refuses never reaches
the code that would have accepted it. One list, exported by the

View File

@@ -0,0 +1,159 @@
/**
* The join between a sheet and the run it produced.
*
* Fixtures use the manifest shape the owning team documented and the sheet shape
* `parseProductSheet` emits.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { IngestProduct } from '@/api/ingest';
import type { SheetProductRow } from '@/api/products';
import { buildImportRequests, planOpeningStock } from './openingStock';
const sheetRow = (over: Partial<SheetProductRow> = {}): SheetProductRow => ({
productname: 'Amul Butter 100g',
productsku: 'ACME-BUT-100',
categoryid: 2,
subcategoryid: 0,
retailprice: 60,
productcost: 45,
taxpercent: 5,
quantity: 24,
...over,
});
const manifest = (over: Partial<IngestProduct> = {}): IngestProduct => ({
image_id: 'amul_amul_butter_100g',
brand: 'amul',
product_name: 'Amul Butter 100g',
product_sku: 'ACME-BUT-100',
sku_source: 'sheet',
disposition: 'inserted',
...over,
});
test('a sheet SKU gives an exact join', () => {
const plan = planOpeningStock([manifest()], [sheetRow()]);
assert.equal(plan.matched.length, 1);
assert.equal(plan.matched[0]?.basis, 'sku');
assert.equal(plan.unmatched.length, 0);
assert.equal(plan.missing.length, 0);
});
// A minted SKU appears nowhere in the sheet, so it must not be used as a key —
// otherwise nothing would ever match on a sheet with no sku column.
test('a minted SKU is not used as a join key; the name carries it', () => {
const plan = planOpeningStock(
[manifest({ product_sku: 'AMUL-BUT-1-001', sku_source: 'Internal' })],
[sheetRow({ productsku: '' })],
);
assert.equal(plan.matched.length, 1);
assert.equal(plan.matched[0]?.basis, 'name', 'should fall back to the name, and say so');
});
// The pipeline explodes a pack-size cell into several products, so the manifest
// legitimately exceeds the sheet. Those arrive with no price and no stock, which
// is exactly the state that hides a product from shoppers — so they are named.
test('extra products from a pack-size explosion are reported, not guessed at', () => {
const plan = planOpeningStock(
[
manifest(),
manifest({
image_id: 'amul_amul_butter_500g',
product_name: 'Amul Butter 500g',
product_sku: 'AMUL-BUT-5-001',
sku_source: 'Internal',
}),
],
[sheetRow()],
);
assert.equal(plan.matched.length, 1);
assert.equal(plan.unmatched.length, 1);
assert.equal(plan.unmatched[0]?.product_name, 'Amul Butter 500g');
});
test('a sheet row the run never produced is reported as missing', () => {
const plan = planOpeningStock([], [sheetRow()]);
assert.equal(plan.missing.length, 1);
});
// Matching is case- and punctuation-insensitive on both keys; a sheet written by
// hand will not agree with a pipeline on spacing.
test('joins ignore case and punctuation', () => {
const plan = planOpeningStock(
[manifest({ product_sku: 'acme but 100' })],
[sheetRow({ productsku: 'ACME-BUT-100' })],
);
assert.equal(plan.matched[0]?.basis, 'sku');
});
/* ── Building the import calls ────────────────────────────────────────────── */
const ids = new Map([['amul_amul_butter_100g', 42]]);
test('one call carries the price and the opening stock together', () => {
const plan = planOpeningStock([manifest()], [sheetRow()]);
const { requests } = buildImportRequests(plan, {
tenantid: 1141,
locationid: 1179,
fallbackCategoryId: 2,
catalogueIds: ids,
});
assert.equal(requests.length, 1);
const req = requests[0]!;
assert.equal(req.catalogueid, 42, 'image_id must resolve to the catalogue row id');
assert.equal(req.quantity, 24, 'the opening stock rides along');
assert.equal(req.stocktype, 'in');
assert.equal(req.retailprice, 60);
assert.equal(req.tenantid, 1141);
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 })]);
const { requests } = buildImportRequests(plan, {
tenantid: 1141,
locationid: 1179,
fallbackCategoryId: 2,
catalogueIds: ids,
});
assert.equal(requests[0]?.categoryid, 2);
});
// A ₹0 product can be ordered for nothing. Refused and named.
test('an unpriced row is refused rather than listed at zero', () => {
const plan = planOpeningStock([manifest()], [sheetRow({ retailprice: 0 })]);
const out = buildImportRequests(plan, {
tenantid: 1141,
locationid: 1179,
fallbackCategoryId: 2,
catalogueIds: ids,
});
assert.equal(out.requests.length, 0);
assert.equal(out.unpriced.length, 1);
});
test('a product whose image_id resolves to nothing is reported, not skipped', () => {
const plan = planOpeningStock([manifest()], [sheetRow()]);
const out = buildImportRequests(plan, {
tenantid: 1141,
locationid: 1179,
fallbackCategoryId: 2,
catalogueIds: new Map(),
});
assert.equal(out.requests.length, 0);
assert.equal(out.unresolved.length, 1);
});
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 });
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 });
assert.equal(b.requests[0]?.quantity, 3);
});

View File

@@ -0,0 +1,222 @@
/**
* Turning a finished ingest run into stock on a shop's shelf.
*
* The upload path splits the work between two systems, and neither carries the
* whole answer:
*
* - The ingest service reads the sheet, enriches it, and writes the products
* into the GLOBAL catalogue. It has no concept of a tenant, an outlet, a
* price or a stock level — the global catalogue is shared by every merchant,
* so those are not facts it could hold.
* - The sheet DOES carry them: a price, a cost, a tax rate and an opening
* stock, per row. They travel no further than this browser.
*
* So the console is what joins them: it keeps the sheet's own columns, waits for
* the run's manifest, and matches one to the other. Every product then goes into
* the merchant's catalogue with its price and its opening stock in a single
* `importcatalogueproduct` call — which writes the product row, the outlet row
* and the stock ledger entry together.
*
* ── Why matching needs care ──────────────────────────────────────────────────
*
* The owning team is explicit: join on `image_id`, never on the product name,
* because a name differing by one character is a different `image_id` and
* therefore a different product — name matching silently updates the wrong row
* or creates a duplicate.
*
* But `image_id` is THEIR key. Our sheet does not contain one; it is derived
* during ingestion. So the join runs the other way round, and the only column
* both sides share is the SKU:
*
* 1. The sheet supplied a SKU → the manifest returns it verbatim with
* `sku_source: "sheet"`. An exact, safe join.
* 2. The sheet left it blank → the pipeline minted one. Nothing in the sheet
* can match it, so the fallback is the normalised product name — and it is
* a FALLBACK, reported as such, not silently trusted.
*
* Anything that matches neither is returned as unmatched rather than guessed at.
* A row that quietly received someone else's price is worse than a row a person
* is told about.
*/
import type { IngestProduct } from '@/api/ingest';
import type { SheetProductRow } from '@/api/products';
/** Where a product's price and opening stock came from. */
export type MatchBasis = 'sku' | 'name';
export interface StockPlanRow {
product: IngestProduct;
row: SheetProductRow;
/** `sku` is exact. `name` is a fallback and worth showing. */
basis: MatchBasis;
}
export interface StockPlan {
/** Products we can price and stock, because the sheet told us how. */
matched: StockPlanRow[];
/**
* Products the run created that no sheet row explains.
*
* Not an error — a pack-size cell reading "100g, 200g, 500g" becomes three
* products from one row, so the manifest legitimately exceeds the sheet. They
* are listed because they will reach the catalogue WITHOUT a price or stock,
* which is exactly the state that makes a product invisible to shoppers.
*/
unmatched: IngestProduct[];
/** Sheet rows the run never produced a product for. */
missing: SheetProductRow[];
}
/** Lowercase, strip everything that is not alphanumeric. */
function key(value: string | undefined): string {
return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
}
/**
* Pairs the run's manifest against the sheet that produced it.
*
* Deliberately pure and synchronous: this is the decision that determines what
* price and how much stock each product gets, and it should be testable without
* a network, a batch, or a tenant.
*/
export function planOpeningStock(
products: readonly IngestProduct[],
rows: readonly SheetProductRow[],
): StockPlan {
const bySku = new Map<string, SheetProductRow>();
const byName = new Map<string, SheetProductRow>();
for (const row of rows) {
const sku = key(row.productsku);
// First writer wins on a duplicate key. A sheet listing the same SKU twice
// is a sheet with a mistake in it, and quietly taking the last one would
// apply a price nobody chose.
if (sku && !bySku.has(sku)) bySku.set(sku, row);
const name = key(row.productname);
if (name && !byName.has(name)) byName.set(name, row);
}
const matched: StockPlanRow[] = [];
const unmatched: IngestProduct[] = [];
const usedRows = new Set<SheetProductRow>();
for (const product of products) {
// The SKU join only holds when the SHEET supplied it. A minted SKU is the
// pipeline's own and appears nowhere in the file.
const sheetSku = product.sku_source === 'sheet' ? key(product.product_sku) : '';
const bySkuHit = sheetSku ? bySku.get(sheetSku) : undefined;
if (bySkuHit) {
matched.push({ product, row: bySkuHit, basis: 'sku' });
usedRows.add(bySkuHit);
continue;
}
const byNameHit = byName.get(key(product.product_name));
if (byNameHit) {
matched.push({ product, row: byNameHit, basis: 'name' });
usedRows.add(byNameHit);
continue;
}
unmatched.push(product);
}
return {
matched,
unmatched,
missing: rows.filter((row) => !usedRows.has(row)),
};
}
/* ── Turning a plan into import requests ──────────────────────────────────── */
export interface ImportRequestRow {
tenantid: number;
locationid: number;
brand: string;
catalogueid: number;
categoryid: number;
subcategoryid: number;
quantity: number;
stocktype: string;
status: string;
retailprice: number;
productcost: number;
taxpercent: number;
}
export interface BuildOptions {
tenantid: number;
locationid: number;
/** Applied to any row whose sheet left `categoryid` blank. */
fallbackCategoryId: number;
/** `image_id` → the catalogue row id `importcatalogueproduct` addresses. */
catalogueIds: ReadonlyMap<string, number>;
}
export interface BuildResult {
requests: ImportRequestRow[];
/**
* Products whose `image_id` could not be resolved to a catalogue row.
*
* The manifest says the pipeline wrote them, so this means the console read
* the catalogue before the write landed, or the brand is one Fiesta cannot
* see. Reported rather than skipped silently: these are products the merchant
* expects to have and will not.
*/
unresolved: IngestProduct[];
/** Rows refused because pricing them would put a ₹0 product on sale. */
unpriced: IngestProduct[];
}
/**
* Builds the import calls, and refuses the two cases that would do harm.
*
* A product with no category cannot be shown by the customer app at all — its
* browse endpoint rejects `categoryid` 0 outright — and a product priced at zero
* can be ordered for nothing. Both have bitten this system before, so neither is
* sent and both come back named.
*/
export function buildImportRequests(plan: StockPlan, options: BuildOptions): BuildResult {
const { tenantid, locationid, fallbackCategoryId, catalogueIds } = options;
const requests: ImportRequestRow[] = [];
const unresolved: IngestProduct[] = [];
const unpriced: IngestProduct[] = [];
for (const { product, row } of plan.matched) {
const catalogueid = catalogueIds.get(product.image_id);
if (catalogueid === undefined) {
unresolved.push(product);
continue;
}
const retailprice = Number(row.retailprice) || 0;
if (retailprice <= 0) {
unpriced.push(product);
continue;
}
requests.push({
tenantid,
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,
// 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)),
stocktype: 'in',
status: 'Active',
retailprice,
productcost: Number(row.productcost) || 0,
taxpercent: Number(row.taxpercent) || 0,
});
}
return { requests, unresolved, unpriced };
}

View File

@@ -1,18 +1,21 @@
import { Drawer } from './Drawer';
import { TenantSheetImportPanel } from './TenantSheetImportPanel';
import { SheetImportPanel } from '@/features/nearle-admin/import/SheetImportPanel';
/**
* Upload a product spreadsheet, from the Products tab.
*
* The panel inside is the one already built for Nearle Admin — reused rather
* than rewritten, because the awkward part is not the UI. There is no batch
* create in Fiesta: `POST /products/create` takes ONE product and does not
* return the id it generated, so an import is N creates, then a lookup by SKU
* to resolve the ids, then one batched location call and one batched stock
* call. The panel stages that so a row the server rejects fails alone.
* The same panel the Nearle Admin uses, with one difference that matters: the
* tenant and branch come from the SESSION rather than being asked for. A shop
* signing in has already answered "whose products are these, and which shelf",
* and asking again would offer a choice with one legal answer — plus a chance
* to pick another merchant by mistake.
*
* It is not idempotent and cannot be made so from here — nothing on the server
* dedupes on SKU — so the panel says that before the button rather than after.
* The sheet goes to the catalogue ingest service and waits in its review inbox;
* once an admin releases it, the products are priced and stocked here from the
* sheet's own columns. That replaces the older path through
* `TenantSheetImportPanel`, which ran N creates straight from the browser
* because Fiesta has no batch create — a row the server rejected there failed
* alone and left the import half done.
*/
export function SheetUploadDrawer({
tenantid,
@@ -30,7 +33,7 @@ export function SheetUploadDrawer({
width={560}
onClose={onClose}
>
<TenantSheetImportPanel tenantid={tenantid} locationid={locationid} />
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
</Drawer>
);
}