e2e changes

This commit is contained in:
2026-09-16 17:12:30 +05:30
parent 5cb9872037
commit f25b3fcf65
15 changed files with 1057 additions and 583 deletions

View File

@@ -71,6 +71,11 @@ export const productsApi = {
* SKU lookup in `importSheetProducts` then read as a product with no
* `productid`: every sheet import resolved zero ids and wrote no locations
* and no stock. Flattened here so no caller sees the grouping.
*
* Nothing calls this today — the importer that did now gets its ids from the
* create response. Kept because it is the only wrapper for a real endpoint
* and the grouping above is the sort of thing the next caller would be
* caught by all over again.
*/
allProducts: (tenantid: number) =>
api
@@ -93,7 +98,18 @@ export const productsApi = {
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
/** Single product only — the backend parses one object, not an array. */
/**
* Single product only — the backend parses one object, not an array.
*
* Answers with the created row, `productid` included. It used to echo back
* the request body, which meant `productid: 0` every time: the id is
* assigned by the database and nothing read it back. Callers that needed it
* — and pricing and stocking a product both do — had to re-read the
* catalogue and find their own row again by SKU.
*
* The payload arrives under `data` rather than `details`, which the client
* already handles.
*/
createProduct: (product: Partial<Product>) =>
api.post<Product>(`${WEB}/products/create`, product),
@@ -267,9 +283,12 @@ export async function importSheetProducts(
const subcategoryIdFor = (row: SheetProductRow): number =>
aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0;
const locationRows: ProductLocationRequest[] = [];
const stockRows: ProductStockRequest[] = [];
for (const [index, row] of rows.entries()) {
try {
await productsApi.createProduct({
const created = await productsApi.createProduct({
tenantid,
productname: row.productname,
productsku: row.productsku,
@@ -286,51 +305,61 @@ export async function importSheetProducts(
productdesc: row.productdesc,
productstatus: 'Active',
});
/*
The id comes back from the create now.
This loop used to collect SKUs, then read the tenant's ENTIRE catalogue
back, build a SKU→product map and match its own rows against it, because
`POST /products/create` answered `productid: 0`. That is fixed on the
backend — the id is the database's and it is returned — so the second
read and the matching are both gone.
Worth saying what the old way actually cost, because it was not only the
extra request. Matching on SKU means matching on a column nothing
enforces: this importer creates duplicates on re-upload by design, and
the map kept the LAST row for a SKU, so a second upload sent the new
product's price and stock to whichever copy happened to win. A row whose
SKU was blank, or trimmed differently by the sheet, could not be found at
all and was reported as "Created, but could not be found again by SKU" —
a message about the console's own bookkeeping that a shop could do
nothing with.
A zero here would be worse than the old behaviour, so it is checked
rather than assumed: the product exists either way, and saying so is more
use than silently pricing product 0.
*/
if (!created?.productid) {
failures.push({
row,
reason: 'Created, but the server did not return its id — price and stock were not set',
});
onProgress?.(index + 1, rows.length);
continue;
}
createdSkus.push(row.productsku);
locationRows.push({
tenantid,
locationid,
productid: created.productid,
price: row.retailprice,
status: 'available',
});
stockRows.push({
tenantid,
locationid,
productid: created.productid,
quantity: row.quantity,
stocktype: 'in',
status: 'Active',
});
} catch (error) {
failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' });
}
onProgress?.(index + 1, rows.length);
}
if (createdSkus.length === 0) {
return { created: 0, linked: 0, stocked: 0, failures };
}
// Resolve the ids the create endpoint refused to hand back.
const all = await productsApi.allProducts(tenantid);
const bySku = new Map<string, Product>();
for (const product of all) {
if (product.productsku) bySku.set(product.productsku, product);
}
const locationRows: ProductLocationRequest[] = [];
const stockRows: ProductStockRequest[] = [];
for (const row of rows) {
if (!createdSkus.includes(row.productsku)) continue;
const product = bySku.get(row.productsku);
if (!product) {
failures.push({ row, reason: 'Created, but could not be found again by SKU' });
continue;
}
locationRows.push({
tenantid,
locationid,
productid: product.productid,
price: row.retailprice,
status: 'available',
});
stockRows.push({
tenantid,
locationid,
productid: product.productid,
quantity: row.quantity,
stocktype: 'in',
status: 'Active',
});
}
if (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
if (stockRows.length > 0) await productsApi.createProductStock(stockRows);

View File

@@ -118,6 +118,19 @@ export interface TenantInfo {
/** Capitalised on the wire. */
Accountname?: string;
status: string;
/**
* How many outlets this merchant has.
*
* Sent by `getalltenants` only, so it is optional — every other endpoint
* returning a `TenantInfo` leaves it out.
*
* The store list used to work this out for itself, by counting how many
* times a tenantid appeared, on the belief that the endpoint returned one
* row per tenant-location pair. It returns one row per tenant and always
* has, so the count was always 1 and the platform's "Branches" total was
* really its tenant total.
*/
branchcount?: number;
}
export interface TenantLocation {
@@ -211,6 +224,21 @@ export interface Product {
productimage?: string;
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
productimages?: string;
/**
* What the global catalogue said about this product when it was imported —
* a JSON-encoded object, held as a string. Parse with `catalogueFactsOf`.
*
* Carries the fields the product table has no columns for: the FSSAI
* licence, nutrition, highlights, providers, the typical retail range and
* the variant key. The import used to drop all of them and the drawer went
* back to the catalogue on every open, which stopped working the moment a
* re-scrape retired the source row — taking a licence number off a product
* the shop was still selling.
*
* Absent on anything that did not come from the catalogue. The drawer still
* falls back to the live lookup for those.
*/
cataloguefacts?: string;
productdesc?: string;
productsku?: string;
brandid?: number;