upload sheet according to the tenant
This commit is contained in:
@@ -36,6 +36,30 @@ export const catalogueApi = {
|
||||
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
|
||||
brands: () => api.list<CatalogueBrand>(`${WEB}/catalogue/getbrands`),
|
||||
|
||||
/**
|
||||
* Every `image_id` → catalogue row id for one brand, in as few calls as the
|
||||
* page size allows.
|
||||
*
|
||||
* Built for reconciling an ingest manifest. Resolving those one at a time is a
|
||||
* request per product — a 500-row sheet would open 500 connections from a
|
||||
* shop's browser — while a brand is at most a few hundred rows and comes back
|
||||
* in one or two pages.
|
||||
*
|
||||
* `pagesize` is deliberately large but bounded, and paging stops on a short
|
||||
* page rather than trusting a total the list endpoint does not return.
|
||||
*/
|
||||
idsByImageId: async (brand: string, pageSize = 500): Promise<Map<string, number>> => {
|
||||
const out = new Map<string, number>();
|
||||
for (let page = 0; page < 20; page += 1) {
|
||||
const rows = await catalogueApi.products({ brand, pageno: page, pagesize: pageSize });
|
||||
for (const row of rows) {
|
||||
if (row.image_id && typeof row.id === 'number') out.set(row.image_id, row.id);
|
||||
}
|
||||
if (rows.length < pageSize) break;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Requires a brand — the backend reads categories from one brand's table. */
|
||||
categories: (brand: string) =>
|
||||
api.list<string>(`${WEB}/catalogue/getcategories`, { brand }),
|
||||
@@ -50,6 +74,24 @@ export const catalogueApi = {
|
||||
product: (brand: string, sku: string) =>
|
||||
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproduct`, { brand, sku }),
|
||||
|
||||
/**
|
||||
* One catalogue row by the id the ingest pipeline treats as canonical.
|
||||
*
|
||||
* An ingest run reports what it wrote as a manifest of `image_id` values, and
|
||||
* `importcatalogueproduct` addresses products by `catalogueid` — the row id.
|
||||
* This is the only bridge between the two, and without it a manifest could
|
||||
* only be matched on the product NAME, which the owning team warns silently
|
||||
* creates duplicates rather than updating.
|
||||
*
|
||||
* Prefer `productsByBrand` below when resolving more than a handful: this is
|
||||
* one request per product.
|
||||
*/
|
||||
productByImageId: (brand: string, imageId: string) =>
|
||||
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproductbyimageid`, {
|
||||
brand,
|
||||
image_id: imageId,
|
||||
}),
|
||||
|
||||
/**
|
||||
* The `(brand, catalogueid)` pairs this tenant has already imported, for
|
||||
* badging "Imported" in the browser. Called without `brand` because the list
|
||||
|
||||
163
src/api/ingest.test.ts
Normal file
163
src/api/ingest.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* The review inbox, and the status that nearly slipped through as success.
|
||||
*
|
||||
* The fixture is the live response to an anonymous upload on 28 Aug 2026 —
|
||||
* `status: "pending"`, every total zero, the file still queued.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
isAwaitingReview,
|
||||
isDismissed,
|
||||
isSettled,
|
||||
productsOf,
|
||||
releasedRunId,
|
||||
summarise,
|
||||
type IngestBatch,
|
||||
} from './ingest';
|
||||
|
||||
const held = {
|
||||
batch_id: '2ee38d06b583454ea0278a7f6de2c87f',
|
||||
status: 'pending',
|
||||
detail: 'Waiting for review. Nothing runs until an admin starts it.',
|
||||
submitted_by: 'anonymous',
|
||||
files_total: 1,
|
||||
files_done: 0,
|
||||
files_failed: 0,
|
||||
totals: {
|
||||
rows_total: 0,
|
||||
products_built: 0,
|
||||
inserted: 0,
|
||||
backfilled: 0,
|
||||
skipped_existing: 0,
|
||||
rejected: 0,
|
||||
},
|
||||
brands: [],
|
||||
files: [{ index: 0, filename: 'qa.csv', status: 'queued' as const }],
|
||||
} satisfies IngestBatch;
|
||||
|
||||
// The bug this guards: `isSettled` used to mean "not queued and not running",
|
||||
// so `pending` counted as finished and the panel rendered a completed import of
|
||||
// zero products for a batch that had not started.
|
||||
test('a batch held for review is not treated as finished', () => {
|
||||
assert.equal(isSettled(held), false, 'a held batch must not read as settled');
|
||||
assert.equal(isAwaitingReview(held), true);
|
||||
});
|
||||
|
||||
test('the summary says it is waiting, not that nothing imported', () => {
|
||||
const line = summarise(held);
|
||||
assert.match(line, /review/i);
|
||||
assert.doesNotMatch(line, /0 added/, 'must not report an import that never ran');
|
||||
});
|
||||
|
||||
test('a real result is still settled', () => {
|
||||
for (const status of ['done', 'partial', 'failed', 'interrupted', 'cancelled'] as const) {
|
||||
assert.equal(isSettled({ ...held, status }), true, `${status} should be settled`);
|
||||
}
|
||||
});
|
||||
|
||||
test('queued and running are still in flight', () => {
|
||||
for (const status of ['queued', 'running'] as const) {
|
||||
assert.equal(isSettled({ ...held, status }), false, `${status} should not be settled`);
|
||||
}
|
||||
});
|
||||
|
||||
// isSettled is written as a positive list precisely so a status nobody
|
||||
// anticipated stalls a spinner rather than fabricating a completed import.
|
||||
test('an unknown future status does not read as finished', () => {
|
||||
const unknown = { ...held, status: 'quarantined' as unknown as IngestBatch['status'] };
|
||||
assert.equal(isSettled(unknown), false);
|
||||
});
|
||||
|
||||
/* ── The drop lifecycle ───────────────────────────────────────────────────── */
|
||||
|
||||
const released = {
|
||||
...held,
|
||||
batch_id: '9f088d949aa9',
|
||||
status: 'pending' as const,
|
||||
files: [{ index: 0, filename: 'qa.csv', status: 'released' as const, released_to: '8dcef8a2ad94' }],
|
||||
} satisfies IngestBatch;
|
||||
|
||||
const dismissed = {
|
||||
...held,
|
||||
files: [{ index: 0, filename: 'qa.csv', status: 'dismissed' as const, released_to: null }],
|
||||
} satisfies IngestBatch;
|
||||
|
||||
// A released drop is not still waiting — the run is one hop away, and treating
|
||||
// it as held would leave the screen saying "queued for review" forever.
|
||||
test('a released drop is no longer awaiting review', () => {
|
||||
assert.equal(releasedRunId(released), '8dcef8a2ad94');
|
||||
assert.equal(isAwaitingReview(released), false);
|
||||
assert.equal(isAwaitingReview(held), true, 'an unreleased drop is still waiting');
|
||||
});
|
||||
|
||||
// Declined is terminal. Polling on is waiting for something that cannot happen.
|
||||
test('a dismissed drop is recognised and reported as declined', () => {
|
||||
assert.equal(isDismissed(dismissed), true);
|
||||
assert.equal(isDismissed(held), false);
|
||||
assert.match(summarise(dismissed), /declined/i);
|
||||
assert.doesNotMatch(summarise(dismissed), /0 added/);
|
||||
});
|
||||
|
||||
test('the manifest is collected across files', () => {
|
||||
const done = {
|
||||
...held,
|
||||
status: 'done' as const,
|
||||
files: [
|
||||
{
|
||||
index: 0,
|
||||
filename: 'a.csv',
|
||||
status: 'done' as const,
|
||||
result: {
|
||||
products: [
|
||||
{
|
||||
image_id: 'amul_amul_butter_100g',
|
||||
brand: 'amul',
|
||||
product_name: 'Amul Butter 100g',
|
||||
product_sku: 'ACME-BUT-100',
|
||||
sku_source: 'sheet',
|
||||
disposition: 'inserted' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies IngestBatch;
|
||||
|
||||
const products = productsOf(done);
|
||||
assert.equal(products.length, 1);
|
||||
// image_id is the join key; matching on name creates duplicates instead of
|
||||
// updating, which is why it is asserted rather than the name.
|
||||
assert.equal(products[0]?.image_id, 'amul_amul_butter_100g');
|
||||
assert.equal(products[0]?.disposition, 'inserted');
|
||||
});
|
||||
|
||||
/* ── retired: the drop is spent, the answer is on the files ───────────────── */
|
||||
|
||||
// A drop released into a run reads `retired`, and the run is elsewhere. Calling
|
||||
// it finished would report an import that is running right now as a completed
|
||||
// import of zero products.
|
||||
test('a retired drop that was released is not finished', () => {
|
||||
const retired = {
|
||||
...held,
|
||||
status: 'retired' as const,
|
||||
files: [
|
||||
{ index: 0, filename: 'qa.csv', status: 'released' as const, released_to: '8dcef8a2ad94' },
|
||||
],
|
||||
} satisfies IngestBatch;
|
||||
|
||||
assert.equal(isSettled(retired), false, 'the run still has to be followed');
|
||||
assert.equal(releasedRunId(retired), '8dcef8a2ad94');
|
||||
});
|
||||
|
||||
// Retired with nothing to follow is genuinely over — otherwise the panel spins
|
||||
// on a drop that no longer exists.
|
||||
test('a retired drop with nowhere to follow is finished', () => {
|
||||
const retired = {
|
||||
...held,
|
||||
status: 'retired' as const,
|
||||
files: [{ index: 0, filename: 'qa.csv', status: 'dismissed' as const, released_to: null }],
|
||||
} satisfies IngestBatch;
|
||||
|
||||
assert.equal(isSettled(retired), true);
|
||||
});
|
||||
@@ -1,44 +1,50 @@
|
||||
/**
|
||||
* The catalogue ingest service — `mcp.nearle.ai.in`.
|
||||
*
|
||||
* A workbook goes up, the service runs its eleven-stage pipeline over every
|
||||
* row, and the products land in the global catalogue's per-brand tables. From
|
||||
* there Fiesta already sees them: `/web/catalogue/getbrands` and
|
||||
* `/web/catalogue/getproducts` read the SAME database this service writes to,
|
||||
* which is why an upload here shows up in the console's catalogue with nothing
|
||||
* in between to build or synchronise.
|
||||
* A spreadsheet goes up, an admin reviews it, and once released the eleven-stage
|
||||
* pipeline writes the products into the global catalogue. From there Fiesta
|
||||
* already sees them: `/web/catalogue/getbrands` and `/web/catalogue/getproducts`
|
||||
* read the SAME database the pipeline writes to, so an upload appears in the
|
||||
* console with nothing in between to build or synchronise.
|
||||
*
|
||||
* ── Batches, not jobs ────────────────────────────────────────────────────────
|
||||
* ── A drop is not a run ──────────────────────────────────────────────────────
|
||||
*
|
||||
* This module used to call `/api/admin/store-catalog/*` — one file, one
|
||||
* `job_id`, jobs held in memory and lost on restart. That API is still
|
||||
* deployed, but the owning team's guidance is to integrate against
|
||||
* `/api/uploads/catalog`, which takes up to twenty files at once and answers
|
||||
* with a `batch_id` that survives a restart.
|
||||
* `POST /api/uploads/catalog` creates a DROP, and nothing runs on arrival. The
|
||||
* files wait in an admin review inbox; only when someone selects them and
|
||||
* presses Start does a RUN begin, under a different id. The drop id stays valid
|
||||
* for the whole lifecycle and its per-file status is how you follow it:
|
||||
*
|
||||
* `POST` answers 202 the moment the files are staged; the outcome arrives from
|
||||
* `GET /api/uploads/catalog/{batch_id}`. Two things about that shape earn their
|
||||
* own handling below:
|
||||
* queued — still in the inbox, nobody has looked
|
||||
* released — accepted; `released_to` is the run, and the results are there
|
||||
* dismissed — declined; nothing further is coming
|
||||
*
|
||||
* - **A file that could not be read stays in the batch** as a failed member
|
||||
* rather than being dropped, so a sender who submitted two files and sees
|
||||
* one knows what became of the other.
|
||||
* - **`partial` is not `done`.** Some files landed and some did not, and four
|
||||
* of five succeeding must never render as flat success.
|
||||
* `resolveBatch` below makes that hop automatically, so callers poll one id and
|
||||
* get whichever record actually has the answer.
|
||||
*
|
||||
* ── The credential never reaches this file ───────────────────────────────────
|
||||
* ── No credential ────────────────────────────────────────────────────────────
|
||||
*
|
||||
* `X-API-Key`, attached by the Vite proxy in development and by nginx in
|
||||
* production, both from `INGEST_TOKEN`. It stays server-side because the bundle
|
||||
* is served to anyone who opens the console.
|
||||
* The drop endpoint takes none, and that is safe precisely because of the review
|
||||
* gate: an unwanted drop costs disk until somebody declines it, never products
|
||||
* in the live catalogue.
|
||||
*
|
||||
* `INGEST_TOKEN` is the SECRET ALONE — the 43-character value. The service
|
||||
* holds `name:role:secret` triples in its own `API_KEYS` and looks keys up by
|
||||
* the secret, so pasting the whole triple fails as "Invalid API key." rather
|
||||
* than as something that names the real mistake.
|
||||
* So `INGEST_TOKEN` should be left EMPTY. nginx omits an empty header, and a
|
||||
* WRONG key is a 401 rather than a downgrade to anonymous — verified against the
|
||||
* live service. A stale token in the environment would therefore break every
|
||||
* upload while looking like a service fault.
|
||||
*
|
||||
* Only the LIST read (`GET /api/uploads/catalog`) still wants a credential;
|
||||
* reading one batch by its id does not, because the id is itself the proof of
|
||||
* having sent it.
|
||||
*/
|
||||
|
||||
const INGEST_BASE = import.meta.env['VITE_INGEST_BASE'] ?? '/ingest';
|
||||
/**
|
||||
* Optional-chained because `import.meta.env` is Vite's, and it is undefined
|
||||
* anywhere Vite is not — the `node --test` runner included. Without the `?.`
|
||||
* this line throws on import, so every test that so much as names this module
|
||||
* fails before it runs, with a TypeError that points here rather than at the
|
||||
* test. Cheap insurance for a value that already has a fallback.
|
||||
*/
|
||||
const INGEST_BASE = import.meta.env?.['VITE_INGEST_BASE'] ?? '/ingest';
|
||||
|
||||
const ROOT = '/api/uploads/catalog';
|
||||
|
||||
@@ -70,6 +76,28 @@ export const ACCEPTED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.tsv'];
|
||||
* waiting to be continued.
|
||||
*/
|
||||
export type BatchStatus =
|
||||
/**
|
||||
* Accepted and staged, but NOTHING RUNS until an admin releases it.
|
||||
*
|
||||
* A review inbox now sits in front of the pipeline — the service answers
|
||||
* `"Waiting for review. Nothing runs until an admin starts it."` — and this
|
||||
* status was not in the contract we were given. It matters far more than an
|
||||
* extra enum member: `isSettled` originally read "not queued and not
|
||||
* running", so `pending` counted as FINISHED and the panel rendered a
|
||||
* completed batch reporting nothing imported. An upload that had not yet
|
||||
* begun would have been shown as a successful import of zero products.
|
||||
*/
|
||||
| 'pending'
|
||||
/**
|
||||
* Every file in this DROP has been released or dismissed — the drop is spent.
|
||||
*
|
||||
* Not an outcome of its own: the answer is on the files. A released file
|
||||
* carries `released_to`, which is where the run actually is; a dismissed one
|
||||
* carries nothing because nothing will come. Treating `retired` as finished
|
||||
* would report a drop that was accepted and is running right now as a
|
||||
* completed import of zero products.
|
||||
*/
|
||||
| 'retired'
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'done'
|
||||
@@ -78,7 +106,44 @@ export type BatchStatus =
|
||||
| 'interrupted'
|
||||
| 'cancelled';
|
||||
|
||||
export type BatchFileStatus = 'queued' | 'running' | 'done' | 'failed';
|
||||
/**
|
||||
* A file inside a drop.
|
||||
*
|
||||
* `released` and `dismissed` are the review inbox's two outcomes and neither is
|
||||
* a result: released means an admin accepted it and the RUN is somewhere else —
|
||||
* follow `released_to` — while dismissed means they declined it and nothing will
|
||||
* ever come. Reading either as a finished import reports products that were
|
||||
* never written.
|
||||
*/
|
||||
export type BatchFileStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'done'
|
||||
| 'failed'
|
||||
| 'released'
|
||||
| 'dismissed';
|
||||
|
||||
/**
|
||||
* One product the pipeline wrote, from the run's manifest.
|
||||
*
|
||||
* `image_id` is the join key and the only safe one. The owning team calls it
|
||||
* "the primary key every other product is deduplicated on", and warns that a
|
||||
* product name differing by one character is a different product — so matching
|
||||
* a manifest on NAME silently creates duplicates instead of updating.
|
||||
*
|
||||
* `unchanged` rows are included on purpose: re-sending a sheet writes nothing,
|
||||
* and omitting them would make a completely successful upload return an empty
|
||||
* list that reads as total failure.
|
||||
*/
|
||||
export interface IngestProduct {
|
||||
image_id: string;
|
||||
brand: string;
|
||||
product_name: string;
|
||||
product_sku?: string;
|
||||
/** `sheet` when the sheet supplied it, `Internal` when the pipeline minted one. */
|
||||
sku_source?: string;
|
||||
disposition: 'inserted' | 'backfilled' | 'unchanged';
|
||||
}
|
||||
|
||||
/** What the pipeline made of one file, once it has finished. */
|
||||
export interface BatchFileResult {
|
||||
@@ -97,6 +162,14 @@ export interface BatchFileResult {
|
||||
unrecognised_columns?: string[];
|
||||
/** Non-null means rows were built but never stored. */
|
||||
storage_error?: string | null;
|
||||
/**
|
||||
* What the run actually wrote, product by product. Returned on the
|
||||
* single-batch read only — the list endpoints omit it, because twenty runs of
|
||||
* thousands of rows is not a list payload.
|
||||
*/
|
||||
products?: IngestProduct[];
|
||||
/** True when the manifest was capped at 5,000 rows for this file. */
|
||||
products_truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface BatchFile {
|
||||
@@ -105,6 +178,15 @@ export interface BatchFile {
|
||||
status: BatchFileStatus;
|
||||
/** Present on a file the service refused to read, and the reason it gives. */
|
||||
detail?: string | null;
|
||||
/**
|
||||
* The RUN this file became once an admin released it.
|
||||
*
|
||||
* Null while it waits and after it is dismissed. The drop id stays valid for
|
||||
* the whole lifecycle — an earlier build deleted the drop on release and the
|
||||
* poll started 404ing, which made running, declined and lost look identical
|
||||
* from outside.
|
||||
*/
|
||||
released_to?: string | null;
|
||||
size_bytes?: number;
|
||||
rows_total?: number;
|
||||
/** Progress through the eleven stages, while it runs. */
|
||||
@@ -163,17 +245,14 @@ export class IngestError extends Error {
|
||||
export interface SubmitOptions {
|
||||
files: File[];
|
||||
/**
|
||||
* Default FALSE. Stage 6 spawns a Playwright subprocess and searches for an
|
||||
* image per row — minutes per batch on one vCPU. Worth turning on
|
||||
* deliberately, never by default.
|
||||
* A label for the review inbox, so the admin can see who sent what.
|
||||
*
|
||||
* Free text, trimmed to 60 characters by the service, and defaulting to
|
||||
* "anonymous" when omitted. It is worth sending: the drop endpoint takes no
|
||||
* credential, so without this every submission in the inbox is indistinguishable
|
||||
* and an admin approving one cannot tell whose it is.
|
||||
*/
|
||||
fetchImages?: boolean;
|
||||
/**
|
||||
* Default FALSE, and not caution: production runs with `USE_OLLAMA=false`, so
|
||||
* asking for it is a documented no-op. Left as an option only so the flag is
|
||||
* not silently unavailable the day that changes.
|
||||
*/
|
||||
useLlm?: boolean;
|
||||
sender?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
@@ -233,20 +312,26 @@ function guardFiles(files: File[]): void {
|
||||
* at all.
|
||||
*/
|
||||
export async function submitBatch(options: SubmitOptions): Promise<IngestBatch> {
|
||||
const { files, fetchImages = false, useLlm = false, signal } = options;
|
||||
const { files, sender = 'nearle-console', signal } = options;
|
||||
guardFiles(files);
|
||||
|
||||
const form = new FormData();
|
||||
for (const file of files) form.append('files', file, file.name);
|
||||
// Labels the drop in the review inbox. The endpoint takes no credential, so
|
||||
// without this every submission arrives as "anonymous" and the admin deciding
|
||||
// whether to run it cannot tell ours from anyone else's.
|
||||
form.append('sender', sender);
|
||||
|
||||
const query = new URLSearchParams({
|
||||
fetch_images: String(fetchImages),
|
||||
use_llm: String(useLlm),
|
||||
});
|
||||
// `use_llm` and `fetch_images` are no longer sent, and passing them is inert.
|
||||
//
|
||||
// They decide how a run behaves and commit the host to outbound work — image
|
||||
// search is minutes per batch on one vCPU — so the choice belongs to the admin
|
||||
// pressing Start, not to whoever dropped the file. Keeping them in the request
|
||||
// would have read like control we do not have.
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${INGEST_BASE}${ROOT}?${query}`, {
|
||||
response = await fetch(`${INGEST_BASE}${ROOT}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
// Content-Type is deliberately unset: the browser adds it WITH the
|
||||
@@ -281,9 +366,95 @@ export async function fetchBatch(batchId: string, signal?: AbortSignal): Promise
|
||||
return readResponse<IngestBatch>(response);
|
||||
}
|
||||
|
||||
/** True once the batch has stopped moving, whatever the outcome. */
|
||||
/**
|
||||
* True when the batch is sitting in the review inbox, untouched.
|
||||
*
|
||||
* Not a failure and not a result — it is waiting for a person. The distinction
|
||||
* has to be explicit, because the two obvious ways to classify it are both
|
||||
* wrong: called finished, the screen reports an import of zero products that
|
||||
* never ran; called in-progress, the browser polls indefinitely for something
|
||||
* only an admin can move.
|
||||
*/
|
||||
export function isAwaitingReview(batch: IngestBatch): boolean {
|
||||
return batch.status === 'pending' && !releasedRunId(batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* The run a released drop became, if an admin has accepted it.
|
||||
*
|
||||
* A drop is a submission, not a run. Releasing it starts a separate batch and
|
||||
* records its id on the file as `released_to`; the drop id keeps working and
|
||||
* keeps saying `released`, so the results are one hop away rather than at the
|
||||
* id you already hold.
|
||||
*
|
||||
* Read off the files rather than the drop, because that is where the service
|
||||
* puts it — a drop of several files can in principle be released in parts.
|
||||
*/
|
||||
export function releasedRunId(batch: IngestBatch): string | null {
|
||||
for (const file of batch.files ?? []) {
|
||||
if (file.released_to) return file.released_to;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an admin declined the drop. Nothing further will ever arrive, so a
|
||||
* client that keeps polling is waiting for something that cannot happen.
|
||||
*/
|
||||
export function isDismissed(batch: IngestBatch): boolean {
|
||||
const files = batch.files ?? [];
|
||||
return files.length > 0 && files.every((file) => file.status === 'dismissed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a drop to its run, once, and returns whichever is the real answer.
|
||||
*
|
||||
* The caller polls a drop id. If it is released, the numbers it wants are on
|
||||
* the RUN — so this hops and returns that instead. Everything else comes back
|
||||
* unchanged, so a caller never has to know a drop and a run are different
|
||||
* things.
|
||||
*/
|
||||
export async function resolveBatch(batch: IngestBatch, signal?: AbortSignal): Promise<IngestBatch> {
|
||||
const runId = releasedRunId(batch);
|
||||
if (!runId || runId === batch.batch_id) return batch;
|
||||
try {
|
||||
return await fetchBatch(runId, signal);
|
||||
} catch {
|
||||
// The drop is still the honest answer if the run cannot be read — better a
|
||||
// stale "released" than an error for something that did succeed.
|
||||
return batch;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every product a finished batch wrote, across its files. */
|
||||
export function productsOf(batch: IngestBatch): IngestProduct[] {
|
||||
return (batch.files ?? []).flatMap((file) => file.result?.products ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the batch has stopped moving, whatever the outcome.
|
||||
*
|
||||
* Listed positively rather than as "not queued and not running". The negative
|
||||
* form silently absorbed every status added later — which is exactly how
|
||||
* `pending` came to read as a completed import the day the review inbox
|
||||
* appeared. A new status now shows up as "not settled" and stalls a spinner,
|
||||
* which is visible, rather than as "done" and fabricates a result.
|
||||
*/
|
||||
export function isSettled(batch: IngestBatch): boolean {
|
||||
return batch.status !== 'queued' && batch.status !== 'running';
|
||||
// A retired drop whose files went nowhere we can follow is over. Normally
|
||||
// resolveBatch has already hopped to the run, or isDismissed has caught a
|
||||
// decline — this is the remainder, and leaving it unsettled would spin a
|
||||
// progress bar on a drop that no longer exists.
|
||||
if (batch.status === 'retired') {
|
||||
return !releasedRunId(batch);
|
||||
}
|
||||
return (
|
||||
batch.status === 'done' ||
|
||||
batch.status === 'partial' ||
|
||||
batch.status === 'failed' ||
|
||||
batch.status === 'interrupted' ||
|
||||
batch.status === 'cancelled'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,9 +473,16 @@ export async function pollBatch(
|
||||
for (;;) {
|
||||
if (signal?.aborted) throw new IngestError('Cancelled.', 0);
|
||||
|
||||
const batch = await fetchBatch(batchId, signal);
|
||||
// Follows a released drop to the run it became, so the caller polls the
|
||||
// thing that actually has progress on it rather than a record that will say
|
||||
// "released" forever.
|
||||
const batch = await resolveBatch(await fetchBatch(batchId, signal), signal);
|
||||
onTick(batch);
|
||||
if (isSettled(batch)) return batch;
|
||||
// Stops on a review hold and on a dismissal as well as on a result. Waiting
|
||||
// for an admin is not progress, a declined drop will never produce one, and
|
||||
// a browser tab cannot outlast either — the drop id is what the operator
|
||||
// comes back with.
|
||||
if (isSettled(batch) || isAwaitingReview(batch) || isDismissed(batch)) return batch;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
@@ -395,7 +573,7 @@ function describe(status: number, payload: unknown, text: string): IngestError {
|
||||
if (status === 429) {
|
||||
return new IngestError(
|
||||
detail ??
|
||||
'Four batches are already queued. This upload was staged rather than lost — wait a moment and send it again.',
|
||||
'The review inbox is full, so NOTHING was stored — this upload was not merely delayed. An admin has to clear it before you resend.',
|
||||
status,
|
||||
body,
|
||||
);
|
||||
@@ -428,6 +606,29 @@ export function isIncomplete(batch: IngestBatch): boolean {
|
||||
export function summarise(batch: IngestBatch): string {
|
||||
const { totals } = batch;
|
||||
|
||||
if (isDismissed(batch)) {
|
||||
// A refusal, not a failure, and nothing further is coming.
|
||||
//
|
||||
// The DROP-level detail is deliberately not used here. It still reads
|
||||
// "Waiting for review. Nothing runs until an admin starts it." on a drop
|
||||
// that has since been declined — the sentence was written when the file was
|
||||
// accepted and nothing rewrites it. Rendering it would tell the operator to
|
||||
// keep waiting for a decision that has already been made against them.
|
||||
//
|
||||
// A reason attached to the FILE is the admin's own and is worth showing.
|
||||
const reason = (batch.files ?? []).map((file) => file.detail).find(Boolean);
|
||||
return reason
|
||||
? `An admin declined this upload: ${reason}`
|
||||
: 'An admin declined this upload. Nothing was imported.';
|
||||
}
|
||||
if (isAwaitingReview(batch)) {
|
||||
// The service's own sentence when it has one — it is clearer than anything
|
||||
// invented here, and it changes if their review policy does.
|
||||
return (
|
||||
batch.detail ??
|
||||
'Waiting for review. Nothing runs until an admin on the ingest service starts it.'
|
||||
);
|
||||
}
|
||||
if (batch.status === 'failed') {
|
||||
return batch.detail ?? 'No file could be ingested.';
|
||||
}
|
||||
|
||||
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