upload sheet according to the tenant

This commit is contained in:
2026-08-29 12:08:38 +05:30
parent 32c3bef3ad
commit 29613e25ee
8 changed files with 1227 additions and 75 deletions

View File

@@ -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
View 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);
});

View File

@@ -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.';
}

View 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.`;
}

View File

@@ -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&rsquo;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&rsquo;s catalogue, on the chosen branch&rsquo;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

View 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);
});

View 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 };
}

View File

@@ -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>
);
}