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 with product counts, for the filter chip row. Never hardcode this list. */
|
||||||
brands: () => api.list<CatalogueBrand>(`${WEB}/catalogue/getbrands`),
|
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. */
|
/** Requires a brand — the backend reads categories from one brand's table. */
|
||||||
categories: (brand: string) =>
|
categories: (brand: string) =>
|
||||||
api.list<string>(`${WEB}/catalogue/getcategories`, { brand }),
|
api.list<string>(`${WEB}/catalogue/getcategories`, { brand }),
|
||||||
@@ -50,6 +74,24 @@ export const catalogueApi = {
|
|||||||
product: (brand: string, sku: string) =>
|
product: (brand: string, sku: string) =>
|
||||||
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproduct`, { brand, sku }),
|
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
|
* The `(brand, catalogueid)` pairs this tenant has already imported, for
|
||||||
* badging "Imported" in the browser. Called without `brand` because the list
|
* 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`.
|
* The catalogue ingest service — `mcp.nearle.ai.in`.
|
||||||
*
|
*
|
||||||
* A workbook goes up, the service runs its eleven-stage pipeline over every
|
* A spreadsheet goes up, an admin reviews it, and once released the eleven-stage
|
||||||
* row, and the products land in the global catalogue's per-brand tables. From
|
* pipeline writes the products into the global catalogue. From there Fiesta
|
||||||
* there Fiesta already sees them: `/web/catalogue/getbrands` and
|
* already sees them: `/web/catalogue/getbrands` and `/web/catalogue/getproducts`
|
||||||
* `/web/catalogue/getproducts` read the SAME database this service writes to,
|
* read the SAME database the pipeline writes to, so an upload appears in the
|
||||||
* which is why an upload here shows up in the console's catalogue with nothing
|
* console with nothing in between to build or synchronise.
|
||||||
* 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
|
* `POST /api/uploads/catalog` creates a DROP, and nothing runs on arrival. The
|
||||||
* `job_id`, jobs held in memory and lost on restart. That API is still
|
* files wait in an admin review inbox; only when someone selects them and
|
||||||
* deployed, but the owning team's guidance is to integrate against
|
* presses Start does a RUN begin, under a different id. The drop id stays valid
|
||||||
* `/api/uploads/catalog`, which takes up to twenty files at once and answers
|
* for the whole lifecycle and its per-file status is how you follow it:
|
||||||
* with a `batch_id` that survives a restart.
|
|
||||||
*
|
*
|
||||||
* `POST` answers 202 the moment the files are staged; the outcome arrives from
|
* queued — still in the inbox, nobody has looked
|
||||||
* `GET /api/uploads/catalog/{batch_id}`. Two things about that shape earn their
|
* released — accepted; `released_to` is the run, and the results are there
|
||||||
* own handling below:
|
* dismissed — declined; nothing further is coming
|
||||||
*
|
*
|
||||||
* - **A file that could not be read stays in the batch** as a failed member
|
* `resolveBatch` below makes that hop automatically, so callers poll one id and
|
||||||
* rather than being dropped, so a sender who submitted two files and sees
|
* get whichever record actually has the answer.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* ── The credential never reaches this file ───────────────────────────────────
|
* ── No credential ────────────────────────────────────────────────────────────
|
||||||
*
|
*
|
||||||
* `X-API-Key`, attached by the Vite proxy in development and by nginx in
|
* The drop endpoint takes none, and that is safe precisely because of the review
|
||||||
* production, both from `INGEST_TOKEN`. It stays server-side because the bundle
|
* gate: an unwanted drop costs disk until somebody declines it, never products
|
||||||
* is served to anyone who opens the console.
|
* in the live catalogue.
|
||||||
*
|
*
|
||||||
* `INGEST_TOKEN` is the SECRET ALONE — the 43-character value. The service
|
* So `INGEST_TOKEN` should be left EMPTY. nginx omits an empty header, and a
|
||||||
* holds `name:role:secret` triples in its own `API_KEYS` and looks keys up by
|
* WRONG key is a 401 rather than a downgrade to anonymous — verified against the
|
||||||
* the secret, so pasting the whole triple fails as "Invalid API key." rather
|
* live service. A stale token in the environment would therefore break every
|
||||||
* than as something that names the real mistake.
|
* 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';
|
const ROOT = '/api/uploads/catalog';
|
||||||
|
|
||||||
@@ -70,6 +76,28 @@ export const ACCEPTED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.tsv'];
|
|||||||
* waiting to be continued.
|
* waiting to be continued.
|
||||||
*/
|
*/
|
||||||
export type BatchStatus =
|
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'
|
| 'queued'
|
||||||
| 'running'
|
| 'running'
|
||||||
| 'done'
|
| 'done'
|
||||||
@@ -78,7 +106,44 @@ export type BatchStatus =
|
|||||||
| 'interrupted'
|
| 'interrupted'
|
||||||
| 'cancelled';
|
| '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. */
|
/** What the pipeline made of one file, once it has finished. */
|
||||||
export interface BatchFileResult {
|
export interface BatchFileResult {
|
||||||
@@ -97,6 +162,14 @@ export interface BatchFileResult {
|
|||||||
unrecognised_columns?: string[];
|
unrecognised_columns?: string[];
|
||||||
/** Non-null means rows were built but never stored. */
|
/** Non-null means rows were built but never stored. */
|
||||||
storage_error?: string | null;
|
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 {
|
export interface BatchFile {
|
||||||
@@ -105,6 +178,15 @@ export interface BatchFile {
|
|||||||
status: BatchFileStatus;
|
status: BatchFileStatus;
|
||||||
/** Present on a file the service refused to read, and the reason it gives. */
|
/** Present on a file the service refused to read, and the reason it gives. */
|
||||||
detail?: string | null;
|
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;
|
size_bytes?: number;
|
||||||
rows_total?: number;
|
rows_total?: number;
|
||||||
/** Progress through the eleven stages, while it runs. */
|
/** Progress through the eleven stages, while it runs. */
|
||||||
@@ -163,17 +245,14 @@ export class IngestError extends Error {
|
|||||||
export interface SubmitOptions {
|
export interface SubmitOptions {
|
||||||
files: File[];
|
files: File[];
|
||||||
/**
|
/**
|
||||||
* Default FALSE. Stage 6 spawns a Playwright subprocess and searches for an
|
* A label for the review inbox, so the admin can see who sent what.
|
||||||
* image per row — minutes per batch on one vCPU. Worth turning on
|
*
|
||||||
* deliberately, never by default.
|
* 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;
|
sender?: string;
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,20 +312,26 @@ function guardFiles(files: File[]): void {
|
|||||||
* at all.
|
* at all.
|
||||||
*/
|
*/
|
||||||
export async function submitBatch(options: SubmitOptions): Promise<IngestBatch> {
|
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);
|
guardFiles(files);
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (const file of files) form.append('files', file, file.name);
|
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({
|
// `use_llm` and `fetch_images` are no longer sent, and passing them is inert.
|
||||||
fetch_images: String(fetchImages),
|
//
|
||||||
use_llm: String(useLlm),
|
// 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;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${INGEST_BASE}${ROOT}?${query}`, {
|
response = await fetch(`${INGEST_BASE}${ROOT}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: form,
|
body: form,
|
||||||
// Content-Type is deliberately unset: the browser adds it WITH the
|
// 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);
|
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 {
|
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 (;;) {
|
for (;;) {
|
||||||
if (signal?.aborted) throw new IngestError('Cancelled.', 0);
|
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);
|
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));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
}
|
}
|
||||||
@@ -395,7 +573,7 @@ function describe(status: number, payload: unknown, text: string): IngestError {
|
|||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
return new IngestError(
|
return new IngestError(
|
||||||
detail ??
|
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,
|
status,
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
@@ -428,6 +606,29 @@ export function isIncomplete(batch: IngestBatch): boolean {
|
|||||||
export function summarise(batch: IngestBatch): string {
|
export function summarise(batch: IngestBatch): string {
|
||||||
const { totals } = batch;
|
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') {
|
if (batch.status === 'failed') {
|
||||||
return batch.detail ?? 'No file could be ingested.';
|
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 { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 type { SheetProductRow } from '@/api/products';
|
||||||
import {
|
import {
|
||||||
ACCEPTED_EXTENSIONS,
|
ACCEPTED_EXTENSIONS,
|
||||||
|
isAwaitingReview,
|
||||||
|
isDismissed,
|
||||||
isIncomplete,
|
isIncomplete,
|
||||||
isSettled,
|
isSettled,
|
||||||
pollBatch,
|
pollBatch,
|
||||||
|
productsOf,
|
||||||
progressOf,
|
progressOf,
|
||||||
submitBatch,
|
submitBatch,
|
||||||
summarise,
|
summarise,
|
||||||
type IngestBatch,
|
type IngestBatch,
|
||||||
} from '@/api/ingest';
|
} from '@/api/ingest';
|
||||||
import { errorMessage } from '@/api/client';
|
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 { SectionHeader } from '@/components/SectionHeader';
|
||||||
import { SheetDropzone } from '@/components/SheetDropzone';
|
import { SheetDropzone } from '@/components/SheetDropzone';
|
||||||
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
|
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
|
* twice, so that is said out loud before the button rather than discovered
|
||||||
* afterwards.
|
* 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 [file, setFile] = useState<File | null>(null);
|
||||||
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
||||||
const [parseError, setParseError] = useState<string | 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>[] = [
|
const previewColumns: TableColumn<PreviewRow>[] = [
|
||||||
{
|
{
|
||||||
key: 'productname',
|
key: 'productname',
|
||||||
@@ -179,7 +260,7 @@ export function SheetImportPanel() {
|
|||||||
|
|
||||||
/* ── Finished ─────────────────────────────────────────────────────────── */
|
/* ── Finished ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
if (batch && isSettled(batch)) {
|
if (batch && (isSettled(batch) || isAwaitingReview(batch) || isDismissed(batch))) {
|
||||||
const { totals } = batch;
|
const { totals } = batch;
|
||||||
const broken = isIncomplete(batch);
|
const broken = isIncomplete(batch);
|
||||||
/* Every file the service refused, with the reason it gave for each. A file
|
/* 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">
|
<Card padding={4} variant="transparent">
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<HStack align="center" gap={1.5}>
|
<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)' }} />
|
<AlertTriangle size={22} style={{ color: 'var(--color-error, #d64545)' }} />
|
||||||
) : broken ? (
|
) : broken ? (
|
||||||
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||||
@@ -207,7 +290,16 @@ export function SheetImportPanel() {
|
|||||||
Batch {batch.batch_id} · {batch.status}
|
Batch {batch.batch_id} · {batch.status}
|
||||||
</Text>
|
</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">
|
<Text type="body" color="secondary">
|
||||||
{totals.rows_total} sheet row{totals.rows_total === 1 ? '' : 's'} became{' '}
|
{totals.rows_total} sheet row{totals.rows_total === 1 ? '' : 's'} became{' '}
|
||||||
{totals.products_built} product{totals.products_built === 1 ? '' : 's'}
|
{totals.products_built} product{totals.products_built === 1 ? '' : 's'}
|
||||||
@@ -296,15 +388,100 @@ export function SheetImportPanel() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* The catalogue is not the shelf. Said here because it is the single
|
{/* Putting them on the shelf.
|
||||||
most likely thing to be misread: the products exist now, and they
|
|
||||||
are still not on sale anywhere. */}
|
The run wrote the GLOBAL catalogue, which is shared by every
|
||||||
{batch.status !== 'failed' ? (
|
merchant and holds no price and no stock. The sheet carries both
|
||||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
|
and has gone no further than this browser, so this step is the only
|
||||||
These are in the global catalogue. To put one on a shop’s shelf, open the
|
place the two can meet. Until it runs, the products exist and no
|
||||||
catalogue, add it to that outlet with a category and a price, then receive stock
|
shop can sell them. */}
|
||||||
against it — a product with no stock is not offered in the customer app.
|
{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>
|
||||||
|
<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}
|
) : null}
|
||||||
|
|
||||||
<RawResponse payload={batch} />
|
<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
|
{/* The service reads .tsv as well, and the dropzone's own default
|
||||||
list did not offer it — a file the picker refuses never reaches
|
list did not offer it — a file the picker refuses never reaches
|
||||||
the code that would have accepted it. One list, exported by the
|
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 { Drawer } from './Drawer';
|
||||||
import { TenantSheetImportPanel } from './TenantSheetImportPanel';
|
import { SheetImportPanel } from '@/features/nearle-admin/import/SheetImportPanel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a product spreadsheet, from the Products tab.
|
* Upload a product spreadsheet, from the Products tab.
|
||||||
*
|
*
|
||||||
* The panel inside is the one already built for Nearle Admin — reused rather
|
* The same panel the Nearle Admin uses, with one difference that matters: the
|
||||||
* than rewritten, because the awkward part is not the UI. There is no batch
|
* tenant and branch come from the SESSION rather than being asked for. A shop
|
||||||
* create in Fiesta: `POST /products/create` takes ONE product and does not
|
* signing in has already answered "whose products are these, and which shelf",
|
||||||
* return the id it generated, so an import is N creates, then a lookup by SKU
|
* and asking again would offer a choice with one legal answer — plus a chance
|
||||||
* to resolve the ids, then one batched location call and one batched stock
|
* to pick another merchant by mistake.
|
||||||
* call. The panel stages that so a row the server rejects fails alone.
|
|
||||||
*
|
*
|
||||||
* It is not idempotent and cannot be made so from here — nothing on the server
|
* The sheet goes to the catalogue ingest service and waits in its review inbox;
|
||||||
* dedupes on SKU — so the panel says that before the button rather than after.
|
* 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({
|
export function SheetUploadDrawer({
|
||||||
tenantid,
|
tenantid,
|
||||||
@@ -30,7 +33,7 @@ export function SheetUploadDrawer({
|
|||||||
width={560}
|
width={560}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<TenantSheetImportPanel tenantid={tenantid} locationid={locationid} />
|
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user