From b4e77fd6ea60a16df7f8e31ac0164fc3806fc086 Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 31 Aug 2026 14:58:55 +0530 Subject: [PATCH] updated on shelf --- src/api/uploads.ts | 29 ++++ .../nearle-admin/import/SheetImportPanel.tsx | 48 ++---- src/features/nearle-admin/import/shelve.ts | 81 +++++++++ src/features/uploads/UploadsPanel.tsx | 157 ++++++++++++++++++ src/features/uploads/shelveRows.test.ts | 50 ++++++ src/features/uploads/shelveRows.ts | 23 +++ 6 files changed, 356 insertions(+), 32 deletions(-) create mode 100644 src/features/nearle-admin/import/shelve.ts create mode 100644 src/features/uploads/shelveRows.test.ts create mode 100644 src/features/uploads/shelveRows.ts diff --git a/src/api/uploads.ts b/src/api/uploads.ts index 5e8094e..8cb4310 100644 --- a/src/api/uploads.ts +++ b/src/api/uploads.ts @@ -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(`${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(`/uploads/sheet`, body), + /** Record the other half: priced, shelved and stocked at a branch. */ markShelved: (body: { batchid: string; shelved: number; skipped: number }) => api.put(`${WEB}/uploads/shelved`, body), diff --git a/src/features/nearle-admin/import/SheetImportPanel.tsx b/src/features/nearle-admin/import/SheetImportPanel.tsx index 26cd45b..0f7ea62 100644 --- a/src/features/nearle-admin/import/SheetImportPanel.tsx +++ b/src/features/nearle-admin/import/SheetImportPanel.tsx @@ -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(); - 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(() => {}); } diff --git a/src/features/nearle-admin/import/shelve.ts b/src/features/nearle-admin/import/shelve.ts new file mode 100644 index 0000000..a46e585 --- /dev/null +++ b/src/features/nearle-admin/import/shelve.ts @@ -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 { + 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(); + 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 }; +} diff --git a/src/features/uploads/UploadsPanel.tsx b/src/features/uploads/UploadsPanel.tsx index 2b2d474..8e4c9e5 100644 --- a/src/features/uploads/UploadsPanel.tsx +++ b/src/features/uploads/UploadsPanel.tsx @@ -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({ + {/* 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 ? ( + + ) : 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(null); + const [note, setNote] = useState(null); + /** The file re-supplied for a receipt whose sheet was never stored. */ + const [rescueFile, setRescueFile] = useState(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 ( + + {storedRows.length > 0 ? ( + <> + + 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. + + +