updated on shelf
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
81
src/features/nearle-admin/import/shelve.ts
Normal file
81
src/features/nearle-admin/import/shelve.ts
Normal 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 };
|
||||
}
|
||||
@@ -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’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 }) {
|
||||
|
||||
50
src/features/uploads/shelveRows.test.ts
Normal file
50
src/features/uploads/shelveRows.test.ts
Normal 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'), []);
|
||||
});
|
||||
23
src/features/uploads/shelveRows.ts
Normal file
23
src/features/uploads/shelveRows.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user