updated on shelf

This commit is contained in:
2026-08-31 14:58:55 +05:30
parent bd133e7cc6
commit b4e77fd6ea
6 changed files with 356 additions and 32 deletions

View File

@@ -65,6 +65,15 @@ export interface UploadReceipt {
uploadedname: string;
/** Rows we parsed before sending — independent of anything the service says. */
rowcount: number;
/**
* The parsed sheet as JSON, or empty when it was never stored.
*
* Empty is the interesting case: it means the prices and opening stock are
* gone, and the only way to shelve this upload is for somebody to hand the
* file over again. Receipts written before this column existed are all in
* that state.
*/
sheetrows?: string;
/* ── Cached from the ingest service ──────────────────────────────────── */
laststatus: string;
@@ -87,6 +96,15 @@ export interface UploadReceipt {
}
export interface RecordUploadBody {
/**
* The parsed sheet as JSON — SKU, price and opening stock per row.
*
* Stored so the shelving step can run later, from the Uploads page, without
* the original file or the tab that sent it. The prices and opening stock
* exist nowhere else: the ingest service's catalogue is shared by every
* merchant and carries neither.
*/
sheetrows?: string;
tenantid: number;
locationid: number;
categoryid: number;
@@ -185,6 +203,17 @@ export const uploadsApi = {
rejected?: number;
}) => api.put<unknown>(`${WEB}/uploads/update`, body),
/**
* Supply the prices and opening stock for a receipt that has none.
*
* The rescue path. A receipt filed before the sheet was stored cannot be
* shelved from anywhere, because the ingest service holds a catalogue every
* merchant shares and it carries neither figure — so the file has to come
* back. Sent as JSON so the shelving can then run without it again.
*/
attachSheet: (body: { batchid: string; sheetrows: string }) =>
api.put<unknown>(`/uploads/sheet`, body),
/** Record the other half: priced, shelved and stocked at a branch. */
markShelved: (body: { batchid: string; shelved: number; skipped: number }) =>
api.put<unknown>(`${WEB}/uploads/shelved`, body),

View File

@@ -26,15 +26,14 @@ import { errorMessage } from '@/api/client';
import { buildSender, uploadsApi } from '@/api/uploads';
import { useAuth } from '@/auth/AuthContext';
import { useTenantLocations, useTenants } from '@/queries/hooks';
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 { type StockPlan } from './openingStock';
import { shelveBatch } from './shelve';
import { SectionHeader } from '@/components/SectionHeader';
import { SheetDropzone } from '@/components/SheetDropzone';
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
@@ -217,6 +216,11 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
uploadedby: user?.userid ?? 0,
uploadedname: user?.name ?? '',
rowcount: parsed?.rows.length ?? 0,
// The sheet itself, so the shelving step can be run later from the
// Uploads page. The prices and opening stock exist nowhere else —
// the ingest service holds a catalogue every merchant shares, which
// carries neither — and this browser is otherwise their only copy.
sheetrows: JSON.stringify(parsed?.rows ?? []),
laststatus: submitted.status,
});
} catch (cause) {
@@ -253,33 +257,13 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
setShelving(null);
setIsWorking(true);
try {
// Narrowed to OUR file. A run can be assembled from several drops, so
// its manifest may carry other senders' products — and matching those
// against our sheet would price and stock someone else's goods into this
// merchant's branch.
const products = productsOf(batch, file ? [file.name] : undefined);
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 });
// One implementation, shared with the Uploads page — see `shelve.ts`.
// This step used to live only here, in a closure over this page's state,
// which meant it could not be run once the tab was gone. It normally is
// gone: the drop waits for their admin, and the run then takes minutes.
const result = await shelveBatch(batch, parsed.rows, target, file?.name);
setPlan(result.plan);
setShelved({ count: result.shelved, skipped: result.skipped });
// The other half of the confirmation, onto the receipt.
//
@@ -301,8 +285,8 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
.markShelved({
// The DROP id, never `batch.batch_id` — see the note on `dropId`.
batchid: dropId,
shelved: requests.length,
skipped: unresolved.length + unpriced.length,
shelved: result.shelved,
skipped: result.skipped,
})
.catch(() => {});
}

View File

@@ -0,0 +1,81 @@
/**
* Putting an ingested batch on a branch's shelf.
*
* The half the catalogue ingest service cannot do, extracted so it has exactly
* one implementation. Their pipeline writes the GLOBAL catalogue, which every
* merchant shares and which therefore holds no price and no stock; the sheet
* carries both. Joining them is what turns "the products exist" into "this shop
* can sell them", and until it runs the products are invisible to shoppers.
*
* It lived inside the import panel, in a closure over that page's state. That
* was wrong in the ordinary case rather than a rare one: a drop waits for the
* ingest service's admin to release it and the run then takes minutes, so by the
* time there is anything to shelve the tab that uploaded the file is usually
* closed — and the step had nowhere else to run from. Now the Uploads page can
* call it days later, from a receipt.
*/
import { catalogueApi } from '@/api/catalogue';
import { productsApi, type SheetProductRow } from '@/api/products';
import { productsOf, type IngestBatch } from '@/api/ingest';
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 {
/** Products priced, shelved and given their opening stock. */
shelved: number;
/** Products the run produced that the sheet could not price. */
skipped: number;
/** Kept so the caller can say which ones, and why. */
plan: StockPlan;
}
/**
* Joins a finished run's manifest to the sheet, and writes the result.
*
* `filename` is not optional in spirit. A run an admin assembles from several
* drops lists every file in it, so its manifest can carry other senders'
* products — and the sheet's price and opening stock are applied to whatever the
* manifest is matched against. Without narrowing, another merchant's product
* sharing a name with one of our rows would be priced and stocked into THIS
* merchant's branch.
*/
export async function shelveBatch(
batch: IngestBatch,
rows: readonly SheetProductRow[],
target: ShelveTarget,
filename?: string,
): Promise<ShelveResult> {
const products = productsOf(batch, filename ? [filename] : undefined);
const plan = planOpeningStock(products, rows);
// One catalogue read per BRAND rather than per product. A 500-row sheet would
// otherwise open 500 requests from a shop's connection.
const brands = [...new Set(plan.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(plan, {
tenantid: target.tenantid,
locationid: target.locationid,
fallbackCategoryId: target.categoryid,
catalogueIds,
});
// One `importcatalogueproduct` call for the batch, not one per product: it
// takes an array, and each element writes the product row, the outlet row and
// the stock ledger entry together.
if (requests.length > 0) await productsApi.importFromCatalogue(requests);
return { shelved: requests.length, skipped: unresolved.length + unpriced.length, plan };
}

View File

@@ -42,6 +42,7 @@ import {
isAwaitingReview,
isDismissed,
isSettled,
ACCEPTED_EXTENSIONS,
isStuckOnMissingRunner,
productsOf,
resolveBatch,
@@ -50,6 +51,11 @@ import {
type IngestProduct,
} from '@/api/ingest';
import { daysUntilExpiry, uploadsApi, type UploadReceipt } from '@/api/uploads';
import type { SheetProductRow } from '@/api/products';
import { readStoredRows } from './shelveRows';
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
import { shelveBatch } from '@/features/nearle-admin/import/shelve';
import { SheetDropzone } from '@/components/SheetDropzone';
import { SectionHeader } from '@/components/SectionHeader';
export interface UploadsPanelProps {
@@ -226,6 +232,9 @@ export function UploadsPanel({ tenantid, locationid }: UploadsPanelProps) {
onToggle={() =>
setExpanded((current) => (current === receipt.batchid ? null : receipt.batchid))
}
/* Re-reads the receipts so the row flips to "on the shelf" without a
manual refresh — the write has already happened server-side. */
onShelved={() => void load()}
showTenant={!tenantid}
/>
))}
@@ -241,12 +250,14 @@ function UploadRow({
live,
isExpanded,
onToggle,
onShelved,
showTenant,
}: {
receipt: UploadReceipt;
live: LiveReading | undefined;
isExpanded: boolean;
onToggle: () => void;
onShelved: () => void;
showTenant: boolean;
}) {
const batch = live?.batch;
@@ -302,6 +313,17 @@ function UploadRow({
</HStack>
</HStack>
{/* Finishing the job, from here.
The step this button runs used to exist only in the tab that did the
upload, which meant it was normally impossible: the drop waits for
the ingest service's admin and the run then takes minutes, so by the
time there is anything to shelve that tab is closed. The products
then sit in the shared catalogue, unpriced and unstocked, and no
screen offered a way to finish. */}
{state.canShelve && !receipt.shelvedat && batch ? (
<ShelveAction receipt={receipt} batch={batch} onDone={onShelved} />
) : null}
{/* The seven-day clock. Their service deletes a drop nobody acts on,
and the remedy — asking an admin to release it — only works before
the deadline, so it cannot wait to be discovered. */}
@@ -336,6 +358,141 @@ function UploadRow({
);
}
/**
* Puts a finished upload's products on the branch's shelf, from the receipt.
*
* The step the ingest service cannot do, and the one that was previously
* strandable. Their pipeline writes the GLOBAL catalogue — shared by every
* merchant, carrying no price and no stock — so the sheet's own figures are the
* only source for both, and shelving is where the two meet.
*
* Two paths, because two kinds of receipt exist:
*
* - **The sheet was stored** (every upload from now on): press the button.
* - **It was not** (uploads filed before the column existed): the prices and
* opening stock are genuinely gone from every system that holds anything, so
* the file has to be handed over again. It is parsed here, used immediately,
* and attached to the receipt so nobody has to find it a second time.
*/
function ShelveAction({
receipt,
batch,
onDone,
}: {
receipt: UploadReceipt;
batch: IngestBatch;
onDone: () => void;
}) {
const [isWorking, setIsWorking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
/** The file re-supplied for a receipt whose sheet was never stored. */
const [rescueFile, setRescueFile] = useState<File | null>(null);
// Empty, "[]" and absent all mean the same thing here: there is nothing to
// price these products from.
const storedRows = useMemo(() => readStoredRows(receipt.sheetrows), [receipt.sheetrows]);
async function run(rows: readonly SheetProductRow[]) {
setError(null);
setNote(null);
setIsWorking(true);
try {
const result = await shelveBatch(
batch,
rows,
{
tenantid: receipt.tenantid,
locationid: receipt.locationid,
// The category chosen at upload time, kept on the receipt precisely
// so this step does not have to guess it later. A product filed
// outside the one the app browses is invisible to shoppers.
categoryid: receipt.categoryid,
},
receipt.filename || undefined,
);
await uploadsApi.markShelved({
batchid: receipt.batchid,
shelved: result.shelved,
skipped: result.skipped,
});
setNote(
result.skipped > 0
? `${result.shelved} on the shelf. ${result.skipped} were left out — the sheet did not price them.`
: `${result.shelved} priced, shelved and stocked.`,
);
onDone();
} catch (cause) {
setError(errorMessage(cause));
} finally {
setIsWorking(false);
}
}
async function handleFile(next: File | null) {
setRescueFile(next);
const chosen = next;
if (!chosen) return;
setError(null);
try {
const parsed = await parseProductSheet(chosen);
// Attached before shelving, not after. If the shelving fails halfway the
// rows are still on the receipt, so the retry does not need the file
// again — which is the whole problem this path exists to solve.
await uploadsApi.attachSheet({
batchid: receipt.batchid,
sheetrows: JSON.stringify(parsed.rows),
});
await run(parsed.rows);
} catch (cause) {
setError(errorMessage(cause));
}
}
return (
<VStack gap={1}>
{storedRows.length > 0 ? (
<>
<Text type="body" size="sm" color="secondary">
These are in the shared catalogue but carry no price and no stock yet. This puts them on{' '}
{receipt.locationname || 'the branch'} with the price and opening stock from the sheet.
</Text>
<HStack>
<Button
label={isWorking ? 'Working…' : 'Put on the shelf'}
variant="primary"
size="sm"
isLoading={isWorking}
isDisabled={isWorking}
onClick={() => void run(storedRows)}
/>
</HStack>
</>
) : (
<VStack gap={1}>
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
The products reached the catalogue, but the sheet&rsquo;s prices and opening stock were
not kept with this upload — so nothing can be put on the shelf without it. Choose the
same spreadsheet again to finish.
</Text>
<SheetDropzone file={rescueFile} accept={ACCEPTED_EXTENSIONS.join(',')} onFile={handleFile} />
</VStack>
)}
{note ? (
<Text type="body" size="sm" style={{ color: 'var(--color-success, #10b981)' }}>
{note}
</Text>
) : null}
{error ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
</VStack>
);
}
/* ── The detail: stages while it runs, products once it has ───────────────── */
function UploadDetail({ receipt, live }: { receipt: UploadReceipt; live: LiveReading | undefined }) {

View File

@@ -0,0 +1,50 @@
/**
* The receipt's stored sheet, and the state that makes shelving impossible.
*
* `sheetrows` is what closes the gap that stranded R mart's first upload. The
* ingest service's catalogue is shared by every merchant and carries neither
* price nor stock, so both live only in the spreadsheet — which used to exist
* nowhere but the browser tab that sent it. By the time their admin releases a
* drop and the run finishes, that tab is normally gone.
*
* So the one distinction that matters here is between "this sheet had no rows"
* and "nobody has supplied them yet". The second is recoverable by handing the
* file over again; the first is not, and rendering them the same way would
* either hide a broken upload or ask for a file that would not help.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { readStoredRows } from './shelveRows';
test('rows stored at upload time come back intact', () => {
const rows = readStoredRows(
JSON.stringify([
{ productname: 'Colin Glass Cleaner 500ml', productsku: 'QA-1', retailprice: 99, quantity: 12 },
]),
);
assert.equal(rows.length, 1);
assert.equal(rows[0]?.retailprice, 99);
assert.equal(rows[0]?.quantity, 12);
});
/*
All four of these are the same situation: there is nothing to price from, and
the file has to be handed over again. They are listed separately because each
arrives by a different route — an old receipt, a receipt written before the
column existed, a service default, and a truncated write.
*/
test('a receipt with no sheet reports none, however the absence arrives', () => {
assert.deepEqual(readStoredRows(undefined), []);
assert.deepEqual(readStoredRows(''), []);
assert.deepEqual(readStoredRows('[]'), []);
assert.deepEqual(readStoredRows('null'), []);
});
// Never throws. This runs while rendering a list of receipts, and one row with
// a malformed column must not take the whole page down — least of all the page
// somebody opened to find out why an upload did not finish.
test('malformed JSON degrades to none rather than throwing', () => {
assert.deepEqual(readStoredRows('{not json'), []);
assert.deepEqual(readStoredRows('{"rows":[]}'), []);
assert.deepEqual(readStoredRows('42'), []);
});

View File

@@ -0,0 +1,23 @@
import type { SheetProductRow } from '@/api/products';
/**
* The sheet stored on a receipt, or none.
*
* Its own module so it can be tested without rendering: this decides whether a
* receipt offers a "Put on the shelf" button or asks for the spreadsheet back,
* and getting it wrong either strands an upload or demands a file that would not
* help.
*
* Every failure returns an empty list rather than throwing. It runs while
* rendering a list of receipts, and one row with a malformed column must not
* take down the page somebody opened to find out why their upload did not
* finish.
*/
export function readStoredRows(sheetrows: string | undefined): SheetProductRow[] {
try {
const parsed = JSON.parse(sheetrows || '[]');
return Array.isArray(parsed) ? (parsed as SheetProductRow[]) : [];
} catch {
return [];
}
}