upload sheet according to the tenant
This commit is contained in:
180
src/features/nearle-admin/import/ImportScope.tsx
Normal file
180
src/features/nearle-admin/import/ImportScope.tsx
Normal 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.`;
|
||||
}
|
||||
@@ -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’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’s catalogue, on the chosen branch’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
|
||||
|
||||
159
src/features/nearle-admin/import/openingStock.test.ts
Normal file
159
src/features/nearle-admin/import/openingStock.test.ts
Normal 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);
|
||||
});
|
||||
222
src/features/nearle-admin/import/openingStock.ts
Normal file
222
src/features/nearle-admin/import/openingStock.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user