initial commit
This commit is contained in:
117
src/api/assistant.ts
Normal file
117
src/api/assistant.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Nearle Buddy.
|
||||
*
|
||||
* Two calls: is this switched on, and here is a question. The tenant is not one
|
||||
* of them — the server reads it from the session token, which is the whole
|
||||
* point. A request body that carried a tenant would be a request body somebody
|
||||
* could edit.
|
||||
*/
|
||||
|
||||
import { api, errorMessage, WEB } from './client';
|
||||
|
||||
/** One tool the assistant ran, so an answer can show its working. */
|
||||
export interface AssistantStep {
|
||||
tool: string;
|
||||
/** `ok` or `refused`. Refusals are shown, not hidden — an answer that quietly
|
||||
dropped one would look like Buddy chose not to look. */
|
||||
outcome: string;
|
||||
rows?: number;
|
||||
detail?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/** One line on an approval card: what is about to change, in checkable detail. */
|
||||
export interface ProposalDetail {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A change Buddy has resolved and is waiting on a person for.
|
||||
*
|
||||
* Nothing has happened when this arrives. The `card` is signed by the server
|
||||
* and opaque here — the console sends it back unchanged, and anything the
|
||||
* browser altered stops it verifying.
|
||||
*/
|
||||
export interface Proposal {
|
||||
summary: string;
|
||||
details?: ProposalDetail[];
|
||||
warning?: string;
|
||||
card: string;
|
||||
}
|
||||
|
||||
export interface AssistantAnswer {
|
||||
reply: string;
|
||||
used?: AssistantStep[];
|
||||
model?: string;
|
||||
/** Console routes showing the rows the answer came from. */
|
||||
sources?: string[];
|
||||
/**
|
||||
* True when the loop hit its own limits, or the model's reply was cut off
|
||||
* mid-sentence. The answer is still worth showing — a partial answer beats a
|
||||
* spinner — but it must not be presented as the whole story.
|
||||
*/
|
||||
incomplete?: boolean;
|
||||
/** Set when a change is resolved and waiting. One card, never a list. */
|
||||
awaiting?: Proposal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the assistant switched on for this deployment?
|
||||
*
|
||||
* Asked once when the panel mounts, and the answer decides whether the composer
|
||||
* is enabled. The panel has said "Not connected yet" since it was built; this is
|
||||
* what finally answers that at runtime rather than at build time.
|
||||
*
|
||||
* Never throws. A console that cannot reach this endpoint should show a
|
||||
* disabled field, not an error page — the panel is beside the work, not the
|
||||
* work itself.
|
||||
*/
|
||||
export async function assistantAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const status = await api.get<{ available?: boolean; reason?: string }>(
|
||||
`${WEB}/assistant/status`,
|
||||
);
|
||||
// The reason goes to the browser console, never to the panel. It names
|
||||
// environment variables — useful to whoever deployed this, meaningless and
|
||||
// faintly alarming to a shopkeeper. "Not connected yet" stays the only
|
||||
// thing on screen; this is how somebody finds out which variable is wrong
|
||||
// without a redeploy to add logging.
|
||||
if (status?.available !== true && status?.reason) {
|
||||
console.warn(`[nearle] Buddy is off: ${status.reason}`);
|
||||
}
|
||||
return status?.available === true;
|
||||
} catch (cause) {
|
||||
// A failed check and a configured-off server both disable the composer, and
|
||||
// both used to leave exactly the same trace: nothing. So "Not connected yet"
|
||||
// was read as "the deployment has no model" when it could equally have been
|
||||
// a 500, an expired session or a blocked request — three problems with three
|
||||
// different fixes, and no way to tell them apart from the screen.
|
||||
console.warn('[nearle] Buddy status check failed:', errorMessage(cause));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a question.
|
||||
*
|
||||
* `agent` names which assistant answers — the console sends the one matching the
|
||||
* page the panel sits beside. Phase 2 ships one, so an unknown name is refused
|
||||
* rather than silently answered by the wrong agent.
|
||||
*/
|
||||
export async function askAssistant(agent: string, question: string): Promise<AssistantAnswer> {
|
||||
return api.post<AssistantAnswer>(`${WEB}/assistant/ask`, { agent, question });
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a change the person has agreed to.
|
||||
*
|
||||
* A separate call with no question in it, because it is a different act: the
|
||||
* card names the action and the session names the person, and no model is
|
||||
* consulted. The server re-checks both against the live database before writing
|
||||
* — so this can legitimately fail with "somebody already approved that", which
|
||||
* is an answer rather than an error.
|
||||
*/
|
||||
export async function approveAssistant(agent: string, card: string): Promise<AssistantAnswer> {
|
||||
return api.post<AssistantAnswer>(`${WEB}/assistant/approve`, { agent, card });
|
||||
}
|
||||
137
src/api/catalogue.ts
Normal file
137
src/api/catalogue.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* The global FMCG catalogue — a separate pgvector database, bridged to tenant
|
||||
* data by the composite key `(brand, catalogueid)`.
|
||||
*
|
||||
* A catalogue row's bare `id` is only unique WITHIN its own brand table:
|
||||
* `brand_dabur.id = 1` and `brand_nestle.id = 1` are different products. Every
|
||||
* call that references a catalogue product sends both.
|
||||
*/
|
||||
|
||||
import { api, WEB } from './client';
|
||||
import type { CatalogueBrand, CatalogueProduct, CatalogueRef } from './types';
|
||||
|
||||
export interface CatalogueQuery {
|
||||
/** Omit to search every brand merged — that is the "show everything" entry point. */
|
||||
brand?: string;
|
||||
category?: string;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
|
||||
export const catalogueApi = {
|
||||
/**
|
||||
* Browse the global catalogue. Called with no `brand` this returns the full
|
||||
* merged, paginated list — the list is never gated behind a brand selector.
|
||||
*/
|
||||
products: (query: CatalogueQuery = {}) =>
|
||||
api.list<CatalogueProduct>(`${WEB}/catalogue/getproducts`, {
|
||||
brand: query.brand,
|
||||
category: query.category,
|
||||
keyword: query.keyword,
|
||||
pageno: query.pageno ?? 0,
|
||||
pagesize: query.pagesize ?? 48,
|
||||
}),
|
||||
|
||||
/** 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 }),
|
||||
|
||||
/**
|
||||
* One catalogue row in full — the fields the import leaves behind
|
||||
* (highlights, nutrients, FSSAI, every image, provider list).
|
||||
*
|
||||
* Returns nothing when a re-scrape has retired the source row, which is
|
||||
* common: the tenant's product is a snapshot and outlives its origin.
|
||||
*/
|
||||
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
|
||||
* mixes brands.
|
||||
*/
|
||||
importedRefs: (tenantid: number) =>
|
||||
api.list<CatalogueRef>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Key for the imported-refs lookup.
|
||||
*
|
||||
* `image_id` when there is one, and the brand-qualified id only as a fallback.
|
||||
* The order matters: the catalogue is rebuilt by scrape and renumbered every
|
||||
* time, so a tick placed by `catalogueid` lands on whatever product now holds
|
||||
* that number — or, far more often, on nothing. Eleven of the nineteen links on
|
||||
* the platform were in that state on 2026-08-31, which showed rows a shop
|
||||
* really held as NOT imported and invited someone to import them again.
|
||||
*
|
||||
* `image_id` is the key the catalogue itself deduplicates on and survives both
|
||||
* a renumber and a rename.
|
||||
*/
|
||||
export function catalogueKey(ref: CatalogueRef | CatalogueProduct): string {
|
||||
const imageId = 'catalogueid' in ref ? ref.imageid : ref.image_id;
|
||||
if (imageId) return `img:${imageId}`;
|
||||
return 'catalogueid' in ref ? `${ref.brand}:${ref.catalogueid}` : `${ref.brand}:${ref.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every key one imported ref can be recognised by.
|
||||
*
|
||||
* A ref carries both halves during the changeover — the stable key it has just
|
||||
* acquired, and the id it was imported under years of scrapes ago. Emitting
|
||||
* both means a browse screen keeps matching products that have not been
|
||||
* relinked yet, instead of showing a shop's own stock as missing until someone
|
||||
* runs the repair.
|
||||
*/
|
||||
export function catalogueKeysOf(ref: CatalogueRef): string[] {
|
||||
const keys: string[] = [];
|
||||
if (ref.imageid) keys.push(`img:${ref.imageid}`);
|
||||
if (ref.catalogueid) keys.push(`${ref.brand}:${ref.catalogueid}`);
|
||||
return keys;
|
||||
}
|
||||
85
src/api/catalogueKey.test.ts
Normal file
85
src/api/catalogueKey.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Which key a catalogue product is recognised by.
|
||||
*
|
||||
* This decides whether the browse screen shows a product as already imported.
|
||||
* Getting it wrong is not cosmetic: a shop's own stock shown as missing gets
|
||||
* imported a second time, and the shop ends up with duplicates.
|
||||
*
|
||||
* The reason it changed: `catalogueid` is renumbered by every re-scrape.
|
||||
* Pepsico's live ids run 3, 6, 9 … 27, 30 — there is no 19, 25 or 26 — so on
|
||||
* 2026-08-31 eleven of the nineteen links on the platform pointed at rows that
|
||||
* no longer existed. `image_id` is the key the catalogue itself deduplicates on
|
||||
* and survives both a renumber and a rename.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { catalogueKey, catalogueKeysOf } from './catalogue';
|
||||
|
||||
test('a product with a stable key is identified by it, not by its id', () => {
|
||||
assert.equal(
|
||||
catalogueKey({ brand: 'pepsico', id: 27, image_id: 'cheetos_chips_2d6bf74f' } as never),
|
||||
'img:cheetos_chips_2d6bf74f',
|
||||
);
|
||||
assert.equal(
|
||||
catalogueKey({ brand: 'pepsico', catalogueid: 27, imageid: 'cheetos_chips_2d6bf74f' } as never),
|
||||
'img:cheetos_chips_2d6bf74f',
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
The two sides have to agree. A ref from Fiesta and a product from the catalogue
|
||||
describe the same thing under different field names — `imageid` and `image_id` —
|
||||
and if they produced different keys the tick would never appear at all.
|
||||
*/
|
||||
test('a ref and a catalogue row agree on the key', () => {
|
||||
const fromFiesta = catalogueKey({
|
||||
brand: 'pepsico',
|
||||
catalogueid: 27,
|
||||
imageid: 'cheetos_chips_2d6bf74f',
|
||||
} as never);
|
||||
const fromCatalogue = catalogueKey({
|
||||
brand: 'pepsico',
|
||||
id: 27,
|
||||
image_id: 'cheetos_chips_2d6bf74f',
|
||||
} as never);
|
||||
assert.equal(fromFiesta, fromCatalogue);
|
||||
});
|
||||
|
||||
// The fallback still has to work: products imported before the column existed
|
||||
// carry only the id, and they are genuinely imported.
|
||||
test('without a stable key the brand-qualified id is used', () => {
|
||||
assert.equal(catalogueKey({ brand: 'dabur', catalogueid: 19 } as never), 'dabur:19');
|
||||
assert.equal(catalogueKey({ brand: 'dabur', id: 19 } as never), 'dabur:19');
|
||||
});
|
||||
|
||||
// Brand-qualified, never bare. Each brand is its own table with its own
|
||||
// sequence, so dabur 19 and pepsico 19 both exist and a bare id would tick the
|
||||
// wrong product.
|
||||
test('the fallback key keeps the brand, because ids repeat across brands', () => {
|
||||
assert.notEqual(
|
||||
catalogueKey({ brand: 'dabur', catalogueid: 19 } as never),
|
||||
catalogueKey({ brand: 'pepsico', catalogueid: 19 } as never),
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
During the changeover a ref carries both. Emitting only the stable key would
|
||||
make every not-yet-relinked product read as missing the moment this shipped —
|
||||
turning a silent problem into a visible one on every shop at once.
|
||||
*/
|
||||
test('a ref is recognised by both keys while the changeover runs', () => {
|
||||
assert.deepEqual(
|
||||
catalogueKeysOf({ brand: 'pepsico', catalogueid: 27, imageid: 'cheetos_chips_2d6bf74f' }),
|
||||
['img:cheetos_chips_2d6bf74f', 'pepsico:27'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a ref with only an id still yields its one key', () => {
|
||||
assert.deepEqual(catalogueKeysOf({ brand: 'dabur', catalogueid: 19 }), ['dabur:19']);
|
||||
});
|
||||
|
||||
// A ref with neither yields nothing rather than a key like "undefined:0" that
|
||||
// would collide with every other broken ref and tick unrelated products.
|
||||
test('a ref with nothing to match on yields no keys at all', () => {
|
||||
assert.deepEqual(catalogueKeysOf({ brand: 'dabur', catalogueid: 0 }), []);
|
||||
});
|
||||
305
src/api/client.ts
Normal file
305
src/api/client.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* The Fiesta HTTP client.
|
||||
*
|
||||
* Everything the console knows about talking to the backend lives here, so the
|
||||
* day the backend starts issuing a session token, this is the only file that
|
||||
* changes. Nothing else in the app calls `fetch`.
|
||||
*/
|
||||
|
||||
import { authHeader, forgetSession, readSessionToken } from '@/auth/token';
|
||||
import type { FiestaEnvelope } from './types';
|
||||
|
||||
/**
|
||||
* A 401 on a call we authenticated means the session is over.
|
||||
*
|
||||
* Twelve hours after signing in, or the moment the signing key is rotated under
|
||||
* an open tab, every request starts coming back 401. Without this the console
|
||||
* keeps sending the dead token and each page renders its own error — which a
|
||||
* shopkeeper reads as "my data has gone", not as "sign in again". The screen
|
||||
* fills with failures and nothing tells them the one thing that would fix it.
|
||||
*
|
||||
* Only when a token was actually SENT. A 401 on an anonymous call is the
|
||||
* server declining to serve a stranger, not a session ending — and the
|
||||
* sign-in probe deliberately posts with no password to read a 401 back, so
|
||||
* reacting to that one would clear the session at the login screen and make
|
||||
* signing in impossible.
|
||||
*
|
||||
* `location.reload()` rather than a router push: the session is held in React
|
||||
* state that this module cannot reach, and a reload is the one move guaranteed
|
||||
* to land on the sign-in screen from anywhere in the app. It happens once,
|
||||
* because the storage is cleared first — the reloaded app has no token, so the
|
||||
* next 401 cannot loop.
|
||||
*/
|
||||
function endDeadSession(path: string): void {
|
||||
if (!readSessionToken()) return;
|
||||
forgetSession();
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[nearle] session rejected on ${path}; signing out`);
|
||||
if (typeof window !== 'undefined') window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Fiesta is.
|
||||
*
|
||||
* Set in `.env` as `VITE_API_BASE`, so the host is declared in one place rather
|
||||
* than inferred here — Vite compiles it into the bundle at build time and both
|
||||
* `npm run dev` and a deployed build use the same value.
|
||||
*
|
||||
* This module briefly decided the host itself, switching on `import.meta.env.DEV`.
|
||||
* Explicit configuration is better: a rule in code that says "development means
|
||||
* this, production means that" is invisible from the outside, and someone
|
||||
* reading `.env` to find the backend would have found nothing.
|
||||
*
|
||||
* The fallback is the REAL HOST, not the same-origin `/fiesta` prefix it used
|
||||
* to be. That prefix looked like a safe degradation and was not: a platform
|
||||
* that writes its own `.env` into the build context (Dokploy does) erases the
|
||||
* committed `VITE_API_BASE`, and the bundle then aims every call at whatever
|
||||
* domain serves the console — `https://app.nearledaily.com/fiesta/live/api/...`
|
||||
* instead of Fiesta. It kept working only because nginx happens to proxy that
|
||||
* prefix, which is what made the misconfiguration invisible.
|
||||
*
|
||||
* Defaulting to the host means a missing variable can no longer silently
|
||||
* re-point the backend at the console's own domain. `Dockerfile` also passes
|
||||
* `VITE_API_BASE` as a build argument, so the value survives an overwritten
|
||||
* `.env`.
|
||||
*
|
||||
* A trailing slash is stripped: every path below starts with `/`, and
|
||||
* `https://host//live/api/...` is a different URL to the upstream router.
|
||||
*
|
||||
* Override per machine with `.env.local`, which is gitignored — set it to
|
||||
* `/fiesta` to route through the dev proxy or nginx instead.
|
||||
*/
|
||||
/**
|
||||
* Optional-chained for the same reason `ingest.ts` is: `import.meta.env` is
|
||||
* Vite's, and it is undefined anywhere Vite is not — the test runner included.
|
||||
* Without the `?.` this line throws on import, so every test that so much as
|
||||
* names a module reaching this one fails before it runs, with a TypeError
|
||||
* pointing here rather than at the test. The value already has a fallback; this
|
||||
* only stops the read itself from being fatal.
|
||||
*/
|
||||
const configuredBase = (import.meta.env?.['VITE_API_BASE'] ?? '').trim();
|
||||
|
||||
export const API_BASE = (configuredBase || 'https://fiesta.nearle.app').replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
);
|
||||
|
||||
/** Every console route lives under this prefix. */
|
||||
export const WEB = '/live/api/v1/web';
|
||||
|
||||
/**
|
||||
* The console's POS reads — counter sales and till presence.
|
||||
*
|
||||
* `/web/pos`, NOT `/pos`. Those are two different doors and the difference is
|
||||
* deliberate on the backend's side (`posroutes.go`): everything under `/v1/pos`
|
||||
* sits behind `middleware.PosAuth`, which verifies a TERMINAL's session token.
|
||||
* The console has no such token and cannot obtain one — `/pos/login` refuses an
|
||||
* account that is not a till account, which is the separation working as
|
||||
* intended.
|
||||
*
|
||||
* That guard currently waves unauthenticated requests through, so calling the
|
||||
* terminal group appeared to work. The routes file says what happens next in as
|
||||
* many words: "the moment `POS_AUTH_REQUIRED=true` is set, every POS screen in
|
||||
* the back office goes dark." The same five reads are registered again under
|
||||
* `/v1/web/pos` for exactly this caller, and that is where they belong.
|
||||
*/
|
||||
export const POS = '/live/api/v1/web/pos';
|
||||
|
||||
/**
|
||||
* The mobile surface, for the two endpoints the web group does not carry.
|
||||
*
|
||||
* Not a preference — `tenants/getstaffs` is registered on `/v1/mob/tenants`
|
||||
* only (`tenantroutes.go:35`), so the web path 404s.
|
||||
*/
|
||||
export const MOB = '/live/api/v1/mob';
|
||||
|
||||
/**
|
||||
* A failed call, carrying the backend's own message.
|
||||
*
|
||||
* Fiesta answers HTTP 200 with `status: false` in several places, so the HTTP
|
||||
* status alone is not enough to tell success from failure — both are checked.
|
||||
*/
|
||||
export class FiestaError extends Error {
|
||||
readonly code: number;
|
||||
readonly endpoint: string;
|
||||
|
||||
constructor(message: string, code: number, endpoint: string) {
|
||||
super(message);
|
||||
this.name = 'FiestaError';
|
||||
this.code = code;
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the backend rejected the call for want of a scoping id.
|
||||
*
|
||||
* The IDOR pass added controller-level guards: an unscoped list call 400s
|
||||
* rather than returning every tenant's rows. That is a bug in the caller,
|
||||
* not a server fault, and it should surface as one.
|
||||
*/
|
||||
get isMissingScope(): boolean {
|
||||
return this.code === 400 && /required/i.test(this.message);
|
||||
}
|
||||
}
|
||||
|
||||
export type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
/** Drops empty params rather than sending `?tenantid=` and getting a 400 back. */
|
||||
function toQueryString(params: Record<string, QueryValue> | undefined): string {
|
||||
if (!params) return '';
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
search.set(key, String(value));
|
||||
}
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
params?: Record<string, QueryValue>;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', params, body, signal } = options;
|
||||
|
||||
// There is exactly one path out of this function and it goes to `fetch`.
|
||||
//
|
||||
// A fixture short-circuit used to sit here, gated on a sessionStorage flag.
|
||||
// It is gone: every screen in every workspace now shows what the API
|
||||
// returned or an error, and there is no longer a mode in which the console
|
||||
// shows something else convincingly.
|
||||
|
||||
const url = `${API_BASE}${path}${toQueryString(params)}`;
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
// `authHeader()` is read per request, never captured: sign-in and sign-out
|
||||
// both happen while the app is running, and a header bound once would keep
|
||||
// authorising calls for whoever signed in first.
|
||||
headers: { Accept: 'application/json', ...authHeader() },
|
||||
signal: signal ?? null,
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch (cause) {
|
||||
// A network failure and a 500 read very differently to a user; keep them
|
||||
// distinguishable rather than collapsing both into "something went wrong".
|
||||
throw new FiestaError(
|
||||
cause instanceof DOMException && cause.name === 'AbortError'
|
||||
? 'Request cancelled'
|
||||
: 'Could not reach the server',
|
||||
0,
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
let envelope: FiestaEnvelope<T>;
|
||||
try {
|
||||
envelope = (await response.json()) as FiestaEnvelope<T>;
|
||||
} catch {
|
||||
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
endDeadSession(path);
|
||||
}
|
||||
|
||||
if (!response.ok || envelope.status === false) {
|
||||
throw new FiestaError(
|
||||
envelope.message ?? `Request failed (HTTP ${response.status})`,
|
||||
envelope.code ?? response.status,
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
// Most handlers put the payload in `details`, but a handful answer with
|
||||
// `data` instead — `products/getallproducts` and `products/create` among the
|
||||
// ones the console calls (`productController.go:400,206`). Reading only
|
||||
// `details` handed those two callers `undefined` with no error anywhere.
|
||||
return (envelope.details ?? envelope.data) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole envelope, for the handful of callers that need `message` or
|
||||
* `tenantform` on success — login being the one that matters.
|
||||
*/
|
||||
async function requestEnvelope<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<FiestaEnvelope<T>> {
|
||||
const { method = 'GET', params, body } = options;
|
||||
const url = `${API_BASE}${path}${toQueryString(params)}`;
|
||||
|
||||
const init: RequestInit = { method, headers: { Accept: 'application/json', ...authHeader() } };
|
||||
if (body !== undefined) {
|
||||
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch {
|
||||
throw new FiestaError('Could not reach the server', 0, path);
|
||||
}
|
||||
|
||||
try {
|
||||
return (await response.json()) as FiestaEnvelope<T>;
|
||||
} catch {
|
||||
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
|
||||
request<T>(path, { method: 'GET', params, signal }),
|
||||
|
||||
/**
|
||||
* A read that returns rows.
|
||||
*
|
||||
* Fiesta answers an empty result with `details: null` about as often as with
|
||||
* `[]` — `Scan` into a nil slice marshals as null, and which one you get
|
||||
* depends on the handler rather than on anything meaningful. A page that maps
|
||||
* over the answer then dies on a white screen, and it dies for the most
|
||||
* ordinary case there is: a tenant with no branches yet, a shop with no
|
||||
* customers.
|
||||
*
|
||||
* So the coercion happens once, here, rather than as `?? []` on forty call
|
||||
* sites where the one that gets forgotten is the one that breaks. A non-array
|
||||
* answer is treated as empty rather than thrown, because the alternative is
|
||||
* an error screen for what is usually "nothing yet".
|
||||
*/
|
||||
list: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
|
||||
request<T[] | null>(path, { method: 'GET', params, signal }).then((rows) =>
|
||||
Array.isArray(rows) ? rows : [],
|
||||
),
|
||||
|
||||
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
request<T>(path, { method: 'POST', body, params }),
|
||||
|
||||
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
request<T>(path, { method: 'PUT', body, params }),
|
||||
|
||||
del: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
request<T>(path, { method: 'DELETE', body, params }),
|
||||
|
||||
envelope: requestEnvelope,
|
||||
};
|
||||
|
||||
/** Normalises anything thrown into a message worth showing a person. */
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (error instanceof FiestaError) return error.message;
|
||||
if (error instanceof Error) return error.message;
|
||||
return 'Something went wrong';
|
||||
}
|
||||
92
src/api/customers.ts
Normal file
92
src/api/customers.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { api, WEB } from './client';
|
||||
|
||||
/**
|
||||
* Customers, as one branch sees them.
|
||||
*
|
||||
* `gettenantcustomers` genuinely branches on `locationid`: with one it INNER
|
||||
* JOINs `tenantcustomers` and returns only the people registered against that
|
||||
* outlet; without one it returns the tenant's whole book. So this is one of the
|
||||
* few reads where the branch scope is honoured server-side rather than by us.
|
||||
*
|
||||
* The pagination is the trap. The controller supplies NO defaults — a missing
|
||||
* `pageno`/`pagesize` becomes `LIMIT 0 OFFSET 0`, which returns an empty list
|
||||
* rather than an error, and reads on screen as "this shop has no customers".
|
||||
* Both are therefore always sent from here, never left to the caller.
|
||||
*/
|
||||
export interface CustomerInfo {
|
||||
customerid: number;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
contactno?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
landmark?: string;
|
||||
doorno?: string;
|
||||
postcode?: string;
|
||||
deliverylocationid?: number;
|
||||
tenantlocationid?: number;
|
||||
applocationid?: number;
|
||||
/**
|
||||
* Where the customer is, as text — the column type in `app_customers`.
|
||||
*
|
||||
* Sent by `gettenantcustomers` on 97% of rows (measured across 31 customers
|
||||
* at 7 shops, 2026-09-15) and simply absent from this interface until now, so
|
||||
* the one nearly-complete piece of geography the backend has about a shop's
|
||||
* customers was invisible to every page.
|
||||
*/
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
/**
|
||||
* Empty on every customer row on the platform — 0 of 31.
|
||||
*
|
||||
* Kept declared because the column exists and a future write could fill it,
|
||||
* but nothing should render an Active/Inactive state from it: a badge that
|
||||
* reads the same on every row is decoration, and one that reads blank is
|
||||
* worse.
|
||||
*/
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CustomerQuery {
|
||||
tenantid: number;
|
||||
/** Omit for the tenant's whole book. */
|
||||
locationid?: number;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
|
||||
export const customersApi = {
|
||||
list: (query: CustomerQuery) =>
|
||||
api.list<CustomerInfo>(`${WEB}/customers/gettenantcustomers`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
keyword: query.keyword || undefined,
|
||||
pageno: query.pageno ?? 1,
|
||||
pagesize: query.pagesize ?? 100,
|
||||
}),
|
||||
};
|
||||
|
||||
/** A display name that never renders as an empty string. */
|
||||
export function customerName(customer: CustomerInfo): string {
|
||||
const name = [customer.firstname, customer.lastname].filter(Boolean).join(' ').trim();
|
||||
return name || customer.contactno || `Customer ${customer.customerid}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where they are, in the shortest true form.
|
||||
*
|
||||
* Door numbers are dropped: "12B" tells a shopkeeper nothing, and the old
|
||||
* console's fallback of "Coimbatore" for anyone without an address invented a
|
||||
* locality for every record that had none.
|
||||
*/
|
||||
export function customerLocality(customer: CustomerInfo): string {
|
||||
const parts = [customer.suburb, customer.city].filter(Boolean) as string[];
|
||||
if (parts.length > 0) return parts.join(', ');
|
||||
const address = (customer.address ?? '').split(',').map((part) => part.trim());
|
||||
const meaningful = address.find((part) => part.length > 3 && !/^\d/.test(part));
|
||||
return meaningful ?? '—';
|
||||
}
|
||||
490
src/api/deliveries.ts
Normal file
490
src/api/deliveries.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import { api, WEB } from './client';
|
||||
import type { RiderInfo } from './types';
|
||||
import type { DeliveryDraft } from '@/features/store-admin/assignDelivery';
|
||||
|
||||
/**
|
||||
* Deliveries — creating them, moving them along, and finding a rider.
|
||||
*
|
||||
* Separate from `insights.ts`, which only READS deliveries. The split is the
|
||||
* same one the backend makes: `getdeliveries` answers "what is out there", and
|
||||
* these three change it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The rider has no device registered.
|
||||
*
|
||||
* Its own error type because the remedy differs from a transport failure: the
|
||||
* rider must open the app and sign in, not retry. Collapsed into a generic
|
||||
* "notification failed", an operator assumes the network is at fault and tries
|
||||
* again forever.
|
||||
*/
|
||||
export class RiderNotReachableError extends Error {
|
||||
constructor(message = 'This rider has no device registered, so they were not told.') {
|
||||
super(message);
|
||||
this.name = 'RiderNotReachableError';
|
||||
}
|
||||
}
|
||||
|
||||
/** What the rider is told. Kept together so the wording stays consistent. */
|
||||
export const RIDER_MESSAGE = {
|
||||
assigned: (count: number) =>
|
||||
count === 1
|
||||
? 'An order has been assigned to you. Kindly accept and process the delivery.'
|
||||
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Which fleet to ask for. One of these, in this order of preference.
|
||||
*
|
||||
* `getriders` scopes by applocation, partner or tenant. It used to be called
|
||||
* with the region ALONE, which asks "who is on duty in this city" — so a
|
||||
* merchant's assign picker offered every on-duty rider in Coimbatore, including
|
||||
* other merchants' own riders and every other partner's.
|
||||
*
|
||||
* Measured 2026-09-09: 118 riders across three regions, and 117 of them belong
|
||||
* to a delivery partner — 75 to partner 44 alone. Exactly one rider on the
|
||||
* platform is a merchant's own. So the region scope was not a harmless default;
|
||||
* it was the only thing holding the picker together while the two real scopes
|
||||
* went unused.
|
||||
*/
|
||||
export interface RiderQuery {
|
||||
/** The merchant's own riders — hired by them, working their branches. */
|
||||
tenantid?: number;
|
||||
/** A delivery partner's riders. One partner supplies many merchants. */
|
||||
partnerid?: number;
|
||||
/**
|
||||
* The delivery region — a CITY, and the fallback for neither of the above.
|
||||
*
|
||||
* Kept because a caller with no merchant in hand still has to ask something,
|
||||
* not because it is the right scope for an assign picker.
|
||||
*/
|
||||
applocationid?: number;
|
||||
}
|
||||
|
||||
export const deliveriesApi = {
|
||||
/**
|
||||
* Riders on duty right now, for one OWNER.
|
||||
*
|
||||
* "On duty" is the backend's word, not a filter added here: the query wants
|
||||
* `app_userpools.onduty = 1` and a `riderlogs` row stamped today with
|
||||
* `logstatus = 0`. So this list empties overnight and refills as riders clock
|
||||
* on, and an empty answer means nobody has started their shift — not that
|
||||
* the shop has no riders. The picker has to say which.
|
||||
*
|
||||
* ── Why this is scoped and used to not be ─────────────────────────────────
|
||||
*
|
||||
* It sent `applocationid` alone, which asks "who is on duty in this city" —
|
||||
* so a merchant's assign picker listed every on-duty rider in Coimbatore,
|
||||
* including other merchants' own riders and every partner's. Nobody hit it
|
||||
* because there is one rider on the platform. `getriders` scopes by
|
||||
* applocation, partner or tenant, in that order, so the caller names which
|
||||
* fleet it means and the region is only a fallback for neither.
|
||||
*/
|
||||
riders: (query: RiderQuery) =>
|
||||
api.list<RiderInfo>(`${WEB}/partners/getriders`, {
|
||||
...(query.tenantid ? { tenantid: query.tenantid } : {}),
|
||||
...(query.partnerid ? { partnerid: query.partnerid } : {}),
|
||||
...(query.tenantid || query.partnerid ? {} : { applocationid: query.applocationid }),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Hand orders to a rider.
|
||||
*
|
||||
* An array, always, because that is what the endpoint takes and because one
|
||||
* call is one transaction: each row inserts a `deliveries` row, copies it to
|
||||
* `deliveryqueues` for the rider's app, and moves the parent order's status.
|
||||
* Verified with three orders in a single call — three deliveries, three
|
||||
* queue rows, nothing duplicated.
|
||||
*
|
||||
* The response carries no ids, only a message, so callers refetch rather
|
||||
* than patching a row in place.
|
||||
*/
|
||||
assign: (rows: DeliveryDraft[]) =>
|
||||
api.post<unknown>(`${WEB}/deliveries/createdeliveries`, rows),
|
||||
|
||||
/**
|
||||
* Tell a rider they have work.
|
||||
*
|
||||
* Deliberately NOT chained into `assign` — the deliveries are committed by
|
||||
* the time this runs, so a failed push must not read as a failed assignment.
|
||||
* Callers fire it afterwards and report the two outcomes separately: a rider
|
||||
* who was never told has work sitting unseen, which the operator needs to
|
||||
* know without being told the assignment failed.
|
||||
*
|
||||
* The backend holds the Firebase credentials; this only relays.
|
||||
*/
|
||||
notify: (token: string, body: string) => {
|
||||
// Checked here rather than at the server: posting an empty token returns
|
||||
// FCM's "exactly one of token, topic or condition must be specified",
|
||||
// which reads as a server fault rather than as a rider who has never
|
||||
// opened the app. Verified against the live endpoint.
|
||||
if (!token.trim()) throw new RiderNotReachableError();
|
||||
return api.post<unknown>(`${WEB}/utils/notifyuser`, {
|
||||
token: token.trim(),
|
||||
notification: { title: 'NearleXpress', body },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Move a delivery along its ladder, or hand it to a different rider.
|
||||
*
|
||||
* `deliveryid` is the only field the backend insists on — it finds the row
|
||||
* with it and derives the parent order from that rather than trusting the
|
||||
* caller's `orderheaderid`, which is why a partial payload is safe here.
|
||||
*
|
||||
* Note what the backend does NOT do: `picked` updates the delivery and stops
|
||||
* there, leaving the order at its previous status. Only pending, delivered
|
||||
* and cancelled are mirrored onto the order.
|
||||
*/
|
||||
update: (body: UpdateDelivery) =>
|
||||
api.put<unknown>(`${WEB}/deliveries/updatedelivery`, body),
|
||||
};
|
||||
|
||||
/**
|
||||
* The delivery lifecycle, lowercase, as `deliveries.orderstatus` stores it.
|
||||
*
|
||||
* `skipped` is real and reachable — the rider got there and nobody was in —
|
||||
* but it is written by the rider's app, not from here, so it is not offered.
|
||||
*/
|
||||
export const DELIVERY_STEPS = ['pending', 'accepted', 'arrived', 'picked', 'active', 'delivered'] as const;
|
||||
export type DeliveryStep = (typeof DELIVERY_STEPS)[number];
|
||||
|
||||
export interface UpdateDelivery {
|
||||
deliveryid: number;
|
||||
orderstatus: string;
|
||||
/** Sent when known so the backend does not have to look it up. */
|
||||
orderheaderid?: number;
|
||||
/** Set to move the job to a different rider. */
|
||||
userid?: number;
|
||||
assigntime?: string;
|
||||
starttime?: string;
|
||||
arrivaltime?: string;
|
||||
pickuptime?: string;
|
||||
deliverytime?: string;
|
||||
canceltime?: string;
|
||||
}
|
||||
|
||||
/* ── Riders as people, not as a fleet ────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One rider being hired.
|
||||
*
|
||||
* Flat, though it lands in three tables — `app_users` for the person,
|
||||
* `ridersettings` for the vehicle and licence, `app_userpools` for their place
|
||||
* in the availability pool. The caller should not have to know the table layout
|
||||
* to hire somebody, and the backend writes all three in one transaction.
|
||||
*
|
||||
* `tenantid` is NOT here. It goes on the query string and the backend takes it
|
||||
* from there, so a payload cannot put a rider on another merchant's books.
|
||||
*/
|
||||
export interface NewRider {
|
||||
userid?: number;
|
||||
firstname: string;
|
||||
lastname?: string;
|
||||
contactno: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
/** The delivery region. Defaulted from the branch — see `RiderDrawer`. */
|
||||
applocationid: number;
|
||||
/**
|
||||
* Whose rider this is — one of these, never both.
|
||||
*
|
||||
* `tenantid` is a merchant's own rider; `partnerid` is a delivery partner's,
|
||||
* who serves several merchants and sits under no single one. The server
|
||||
* refuses neither and refuses both, so the two can never be confused
|
||||
* downstream in a directory or an assign picker.
|
||||
*/
|
||||
tenantid?: number;
|
||||
partnerid?: number;
|
||||
/** The branch an OWN rider works out of. Meaningless for a partner's. */
|
||||
locationid?: number;
|
||||
shiftid: number;
|
||||
identificationno?: string;
|
||||
vehiclename?: string;
|
||||
vehicleno?: string;
|
||||
licenseno?: string;
|
||||
registrationno?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One rider in the directory.
|
||||
*
|
||||
* `isonduty` is the field to read for "are they working right now" — `onduty`
|
||||
* is the availability flag, which is 1 for anyone who may be given work at all.
|
||||
* A rider hired this morning has `onduty: 1` and `isonduty: false` until they
|
||||
* open the app and start a shift.
|
||||
*/
|
||||
export interface RiderRosterRow {
|
||||
userid: number;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
fullname?: string;
|
||||
contactno?: string;
|
||||
email?: string;
|
||||
tenantid?: number;
|
||||
/** The branch an own rider works out of, and its name. */
|
||||
locationid?: number;
|
||||
locationname?: string;
|
||||
applocationid?: number;
|
||||
applocation?: string;
|
||||
partnerid?: number;
|
||||
partnername?: string;
|
||||
shiftid?: number;
|
||||
shiftname?: string;
|
||||
identificationno?: string;
|
||||
vehiclename?: string;
|
||||
vehicleno?: string;
|
||||
licenseno?: string;
|
||||
registrationno?: string;
|
||||
/** May be given work at all. */
|
||||
onduty?: number;
|
||||
lastlogdate?: string;
|
||||
/** On shift right now — a log dated today. */
|
||||
isonduty?: boolean;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface Partner {
|
||||
partnerid: number;
|
||||
partnername?: string;
|
||||
companyname?: string;
|
||||
applocationid?: number;
|
||||
primarycontact?: string;
|
||||
primaryemail?: string;
|
||||
contactno?: string;
|
||||
registrationno?: string;
|
||||
address?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** One region a partner covers — a row of `partnerlocations`. */
|
||||
export interface PartnerLocation {
|
||||
partnerlocationid: number;
|
||||
partnerid: number;
|
||||
applocationid: number;
|
||||
applocation?: string;
|
||||
}
|
||||
|
||||
/** A delivery region. `applocationid=0` asks for all of them. */
|
||||
export interface AppLocation {
|
||||
applocationid: number;
|
||||
locationname?: string;
|
||||
}
|
||||
|
||||
/** Everything the console collects to onboard a delivery partner. */
|
||||
export interface NewPartner {
|
||||
partnerid?: number;
|
||||
partnername: string;
|
||||
companyname?: string;
|
||||
registrationno?: string;
|
||||
primarycontact: string;
|
||||
primaryemail?: string;
|
||||
contactno?: string;
|
||||
address?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postcode?: number;
|
||||
status?: string;
|
||||
/** The district they work — one, never a set. */
|
||||
applocationid: number;
|
||||
/**
|
||||
* The district by NAME, for one Nearle has not opened yet.
|
||||
*
|
||||
* Sending it opens the district: the server writes the `app_location` and
|
||||
* `app_locationconfig` rows every rider query joins through. Ignored when
|
||||
* `applocationid` is set, which is the ordinary case.
|
||||
*/
|
||||
district?: string;
|
||||
}
|
||||
|
||||
export interface RiderShift {
|
||||
shiftid: number;
|
||||
shiftname?: string;
|
||||
starttime?: string;
|
||||
endtime?: string;
|
||||
shifthours?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new working window.
|
||||
*
|
||||
* Times go as `HH:MM`; the server normalises `9:00` and `09:00:00` to the same
|
||||
* thing, because the dropdown labels a shift by concatenating the two columns
|
||||
* and the rows inserted by hand over the years use every spelling.
|
||||
*/
|
||||
export interface NewRiderShift {
|
||||
applocationid: number;
|
||||
shiftname?: string;
|
||||
starttime: string;
|
||||
endtime: string;
|
||||
basefare?: number;
|
||||
additionalcharges?: number;
|
||||
fuelcharge?: number;
|
||||
}
|
||||
|
||||
export const ridersApi = {
|
||||
/**
|
||||
* The directory — everyone, working today or not.
|
||||
*
|
||||
* NOT `getriders`, which requires a clock-in dated today. That one answers
|
||||
* "who can take this delivery now" and is right for the assign picker; used
|
||||
* as a staff list it hides the rider you just created, which reads as a
|
||||
* failed save.
|
||||
*/
|
||||
roster: (tenantid: number) =>
|
||||
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { tenantid }),
|
||||
|
||||
/**
|
||||
* Hire one for a MERCHANT. `tenantid` travels as a param — the backend takes
|
||||
* the scope from there rather than trusting the body, so a store admin cannot
|
||||
* put a rider on another merchant's books by editing a payload.
|
||||
*/
|
||||
create: (tenantid: number, rider: NewRider) =>
|
||||
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { tenantid }),
|
||||
|
||||
/**
|
||||
* Hire one for a delivery PARTNER.
|
||||
*
|
||||
* Same endpoint, same rider — what differs is who they ride for. A partner
|
||||
* has no console of its own, so their riders are added by the platform.
|
||||
*/
|
||||
createForPartner: (partnerid: number, rider: NewRider) =>
|
||||
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { partnerid }),
|
||||
|
||||
/** A partner's riders, for the platform's directory. */
|
||||
partnerRoster: (partnerid: number) =>
|
||||
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { partnerid }),
|
||||
|
||||
update: (rider: NewRider & { userid: number }) =>
|
||||
api.put<unknown>(`${WEB}/partners/updaterider`, rider),
|
||||
|
||||
/** Shifts to choose from. Scoped by region, and the param is required. */
|
||||
shifts: (applocationid: number) =>
|
||||
api.list<RiderShift>(`${WEB}/partners/getridershifts`, { applocationid }),
|
||||
|
||||
/**
|
||||
* Open a shift window in a region.
|
||||
*
|
||||
* A rider cannot be hired without a shift, and this table could only be read
|
||||
* until now — so a region that shipped with no shift rows was a region no
|
||||
* rider could ever be added to, from anywhere in the product. The drawer
|
||||
* showed "No shifts set up for this region" and that was the end of it.
|
||||
*
|
||||
* `shifthours` is deliberately not sent. The server works it out from the two
|
||||
* times, because it feeds rider pay and is the one field a person gets wrong
|
||||
* with nothing downstream to catch it.
|
||||
*/
|
||||
createShift: (shift: NewRiderShift) =>
|
||||
api.post<RiderShift>(`${WEB}/partners/createridershift`, shift),
|
||||
|
||||
/** Delivery partners a rider can ride for. */
|
||||
partners: (applocationid: number) =>
|
||||
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Delivery partners — the companies that supply riders.
|
||||
*
|
||||
* A partner is onboarded by the platform and then ASSIGNED to merchants; a
|
||||
* merchant never creates one. That split is why `assign` lives on the tenant
|
||||
* API and not here, and why `partnerid` is kept out of the merchant-editable
|
||||
* profile allowlist on the server.
|
||||
*
|
||||
* One partner routinely serves many merchants: partner 44 supplies 48 of them
|
||||
* and partner 60 supplies 63, measured on 2026-09-09.
|
||||
*/
|
||||
export const partnersApi = {
|
||||
/** Every partner in a region. `applocationid` 0 is not accepted here. */
|
||||
list: (applocationid: number) =>
|
||||
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
|
||||
|
||||
/** One partner, by id. */
|
||||
byId: (partnerid: number) =>
|
||||
api.list<Partner>(`${WEB}/partners/getpartners`, { partnerid }),
|
||||
|
||||
create: (partner: NewPartner) =>
|
||||
api.post<{ partnerid: number }>(`${WEB}/partners/createpartner`, partner),
|
||||
|
||||
/**
|
||||
* Edit a partner. Regions are REPLACED when sent and left alone when not, so
|
||||
* an edit that changes only a phone number cannot empty the list.
|
||||
*/
|
||||
update: (partner: NewPartner & { partnerid: number }) =>
|
||||
api.put<unknown>(`${WEB}/partners/updatepartner`, partner),
|
||||
|
||||
/** The regions one partner covers. */
|
||||
locations: (partnerid: number) =>
|
||||
api.list<PartnerLocation>(`${WEB}/partners/getpartnerlocations`, { partnerid }),
|
||||
|
||||
/**
|
||||
* Every GPS ping a partner's riders sent over a window.
|
||||
*
|
||||
* ── Scope, and why this is a platform endpoint ────────────────────────────
|
||||
*
|
||||
* It filters on `partnerid` or on the rider's `applocationid` — never on a
|
||||
* tenant. A partner's riders serve every merchant that partner supplies, so
|
||||
* there is no tenant this could be scoped to, and asking by region would hand
|
||||
* one merchant every rider in the city. That is why the fleet view lives in
|
||||
* the platform console and not in a shop's.
|
||||
*
|
||||
* ── What comes back, and what it is not ───────────────────────────────────
|
||||
*
|
||||
* One row per ping: rider, timestamp, latitude, longitude. Dense — 320,132
|
||||
* rows across eight riders for August 2026 — so a month-wide window is a
|
||||
* large response and callers ask for a day or two at a time.
|
||||
*
|
||||
* The coordinates are NOT a trail. Every row for a given rider carries the
|
||||
* same pair: one rider's 2,404 pings on 14 August 2026 all read 11.052998,
|
||||
* 76.929958, and the same holds on every day and region checked. The app
|
||||
* stamps a location once and repeats it on each heartbeat, so distance, speed
|
||||
* and "time moving" cannot be derived from this and anything of that shape
|
||||
* would be invented. Rider positions that actually move are written on the
|
||||
* `deliveries` rows; see `deliveryTrack`.
|
||||
*
|
||||
* The row also carries `login`, `logout`, `workhours`, `shorthours` and
|
||||
* `breakhours`, and every one of them is empty or zero on every row measured.
|
||||
* Nothing closes a shift. So the timestamps are what this endpoint is good
|
||||
* for — who was online and for how long — and shifts are inferred from the
|
||||
* gaps between pings; see `riderShifts`.
|
||||
*/
|
||||
riderLogs: (query: { partnerid?: number; applocationid?: number; fromdate: string; todate: string }) =>
|
||||
api.list<RiderPingRow>(`${WEB}/partners/getriderlogs`, {
|
||||
...(query.partnerid ? { partnerid: query.partnerid } : {}),
|
||||
...(query.applocationid ? { applocationid: query.applocationid } : {}),
|
||||
fromdate: query.fromdate,
|
||||
todate: query.todate,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* One row of `getriderlogs`.
|
||||
*
|
||||
* The shift columns are typed because they are sent, and documented as empty
|
||||
* because they are: nothing on the platform writes them. Reading `workhours`
|
||||
* and believing it is the mistake this comment exists to prevent.
|
||||
*/
|
||||
export interface RiderPingRow {
|
||||
logid?: number;
|
||||
logdate: string;
|
||||
userid: number;
|
||||
username?: string;
|
||||
partnerid?: number;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
shiftid?: number;
|
||||
shifthours?: number;
|
||||
/** Always empty on production data. See `riderLogs`. */
|
||||
login?: string;
|
||||
/** Always empty on production data. See `riderLogs`. */
|
||||
logout?: string;
|
||||
/** Always 0 on production data. See `riderLogs`. */
|
||||
workhours?: number;
|
||||
shorthours?: number;
|
||||
breakhours?: number;
|
||||
logstatus?: number;
|
||||
}
|
||||
370
src/api/ingest.test.ts
Normal file
370
src/api/ingest.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 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,
|
||||
pollDelayFor,
|
||||
currentStage,
|
||||
isStuckOnMissingRunner,
|
||||
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);
|
||||
});
|
||||
|
||||
/* ── Cross-drop contamination ─────────────────────────────────────────────── */
|
||||
|
||||
// An admin can assemble one run from several drops, so a run's manifest can
|
||||
// carry other senders' products. Applying our sheet's price and opening stock
|
||||
// to those would stock someone else's goods into our merchant's branch.
|
||||
test('only our own file contributes products', () => {
|
||||
const run = {
|
||||
...held,
|
||||
status: 'done' as const,
|
||||
files: [
|
||||
{
|
||||
index: 0,
|
||||
filename: 'ours.csv',
|
||||
status: 'done' as const,
|
||||
result: {
|
||||
products: [
|
||||
{ image_id: 'amul_a', brand: 'amul', product_name: 'Ours', disposition: 'inserted' as const },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
filename: 'someone-elses.csv',
|
||||
status: 'done' as const,
|
||||
result: {
|
||||
products: [
|
||||
{ image_id: 'amul_b', brand: 'amul', product_name: 'Theirs', disposition: 'inserted' as const },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies IngestBatch;
|
||||
|
||||
const mine = productsOf(run, ['ours.csv']);
|
||||
assert.equal(mine.length, 1);
|
||||
assert.equal(mine[0]?.product_name, 'Ours');
|
||||
|
||||
// Unfiltered still returns everything — the filter is the caller's decision,
|
||||
// and every caller that prices products must make it.
|
||||
assert.equal(productsOf(run).length, 2);
|
||||
});
|
||||
|
||||
/*
|
||||
The stage timeline, and the runner that silently isn't there.
|
||||
|
||||
Both arrived with the ingest team's 31 Aug documentation update. The timeline is
|
||||
what lets the console draw the real eleven stages instead of a file-count bar;
|
||||
the runner is a trap, and the more important of the two.
|
||||
*/
|
||||
|
||||
const runningFile = {
|
||||
index: 0,
|
||||
filename: 'catalog.csv',
|
||||
status: 'running' as const,
|
||||
stage_index: 6,
|
||||
stage_name: 'Image Search & Contamination Filtering',
|
||||
total_stages: 11,
|
||||
rows_done: 120,
|
||||
rows_total: 400,
|
||||
stages: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Brand Resolution & FSSAI Licence Mapping',
|
||||
rows_done: 400,
|
||||
rows_total: 400,
|
||||
started_at: 1756612800.1,
|
||||
finished_at: 1756612801.4,
|
||||
},
|
||||
{
|
||||
index: 6,
|
||||
name: 'Image Search & Contamination Filtering',
|
||||
rows_done: 120,
|
||||
rows_total: 400,
|
||||
started_at: 1756612809.7,
|
||||
finished_at: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// `finished_at: null` is the marker, not the last array entry and not
|
||||
// `stage_index`. Reading the position any other way breaks the moment a stage
|
||||
// completes out of order or the array carries a trailing finished entry.
|
||||
test('the running stage is the one with no finish time', () => {
|
||||
const stage = currentStage(runningFile);
|
||||
assert.equal(stage?.index, 6);
|
||||
assert.equal(stage?.rows_done, 120);
|
||||
});
|
||||
|
||||
// A finished file keeps its history, which is the whole reason the timeline
|
||||
// exists — the scalars only ever describe the present moment, and for a
|
||||
// finished file that moment is over.
|
||||
test('a finished file still reports its last stage', () => {
|
||||
const done = {
|
||||
...runningFile,
|
||||
status: 'done' as const,
|
||||
stages: runningFile.stages.map((s) => ({ ...s, finished_at: s.finished_at ?? 1756612900.0 })),
|
||||
};
|
||||
assert.equal(currentStage(done)?.index, 6);
|
||||
});
|
||||
|
||||
// A service build that predates the timeline still has to render. The scalars
|
||||
// are the fallback, not the source of truth.
|
||||
test('a response without a timeline falls back to the scalars', () => {
|
||||
const { stages: _stages, ...noTimeline } = runningFile;
|
||||
const stage = currentStage(noTimeline);
|
||||
assert.equal(stage?.index, 6);
|
||||
assert.equal(stage?.name, 'Image Search & Contamination Filtering');
|
||||
});
|
||||
|
||||
test('a file that has not started reports no stage at all', () => {
|
||||
assert.equal(currentStage({ index: 0, filename: 'a.csv', status: 'queued' }), null);
|
||||
});
|
||||
|
||||
/*
|
||||
`runner: "dagster"` never runs in production — Dagster is a development tool,
|
||||
absent from the deployed image — so the batch waits for a worker that will never
|
||||
claim it. Every visible signal is identical to a batch merely waiting its turn,
|
||||
which is exactly why it has to be named rather than rendered as progress.
|
||||
*/
|
||||
test('a batch staged for the absent orchestrator is called out', () => {
|
||||
assert.equal(isStuckOnMissingRunner({ ...held, status: 'queued', runner: 'dagster' }), true);
|
||||
});
|
||||
|
||||
test('the in-process runner is not a stall', () => {
|
||||
assert.equal(isStuckOnMissingRunner({ ...held, status: 'queued', runner: 'inprocess' }), false);
|
||||
});
|
||||
|
||||
// A batch that reached `running` plainly found an executor, whatever it was
|
||||
// staged for. Warning then would contradict the progress on screen.
|
||||
test('a batch already running is not stuck, whatever it was staged for', () => {
|
||||
assert.equal(isStuckOnMissingRunner({ ...held, status: 'running', runner: 'dagster' }), false);
|
||||
});
|
||||
|
||||
/*
|
||||
A drop is not a run, and the difference is easy to lose.
|
||||
|
||||
`released_to` lives on the DROP's files. Once an admin releases it, following
|
||||
that pointer lands on the run — and the run carries no `released_to` of its own,
|
||||
because nothing released it. So a caller who resolves first and asks for the run
|
||||
id second gets null, and the only pointer from the id they hold to the id with
|
||||
the results is never recorded.
|
||||
|
||||
This cost a real bug in both directions: the Uploads page never saved a run id,
|
||||
and the import panel keyed the shelving write on `batch.batch_id` — which by
|
||||
then was the run — updating a receipt row that does not exist, silently.
|
||||
*/
|
||||
test('the run id is on the drop, and gone from the run it points to', () => {
|
||||
const drop = {
|
||||
...held,
|
||||
batch_id: 'drop-1',
|
||||
status: 'retired' as const,
|
||||
files: [
|
||||
{ index: 0, filename: 'catalog.csv', status: 'released' as const, released_to: 'run-1' },
|
||||
],
|
||||
};
|
||||
assert.equal(releasedRunId(drop), 'run-1');
|
||||
|
||||
// The same question asked of the run answers null. Read the drop first.
|
||||
const run = {
|
||||
...held,
|
||||
batch_id: 'run-1',
|
||||
status: 'done' as const,
|
||||
files: [{ index: 0, filename: 'catalog.csv', status: 'done' as const }],
|
||||
};
|
||||
assert.equal(releasedRunId(run), null);
|
||||
|
||||
// And the two ids differ, which is exactly why a receipt keyed on the drop
|
||||
// cannot be written using the run's.
|
||||
assert.notEqual(drop.batch_id, run.batch_id);
|
||||
});
|
||||
|
||||
/* ── How often to look, and when to stop looking ──────────────────────────── */
|
||||
|
||||
/*
|
||||
The console used to stop polling the moment a drop went to review, on the
|
||||
reasoning that waiting for an admin is not progress. It is not — but the release
|
||||
IS, and stopping there meant the panel said "waiting for review" until somebody
|
||||
reloaded the page. A step-by-step panel that only advances on reload is the
|
||||
thing the panel exists to replace.
|
||||
*/
|
||||
|
||||
test('a review hold is polled slowly, not abandoned', () => {
|
||||
assert.equal(isAwaitingReview(held), true);
|
||||
assert.equal(pollDelayFor(held), 15000, 'a hold can last hours; 2s would be 1,800 reads an hour');
|
||||
});
|
||||
|
||||
test('a running batch is polled at a pace a person can watch', () => {
|
||||
const running = { ...held, status: 'running' } satisfies IngestBatch;
|
||||
assert.equal(isAwaitingReview(running), false);
|
||||
assert.equal(pollDelayFor(running), 2000);
|
||||
});
|
||||
|
||||
// A released drop is no longer waiting on anybody, so it goes back to the fast
|
||||
// cadence even though its own status still reads "pending".
|
||||
test('a released drop is followed at the running pace', () => {
|
||||
const released = {
|
||||
...held,
|
||||
files: [{ index: 0, filename: 'qa.csv', status: 'queued' as const, released_to: 'run-77' }],
|
||||
} satisfies IngestBatch;
|
||||
assert.equal(releasedRunId(released), 'run-77');
|
||||
assert.equal(isAwaitingReview(released), false);
|
||||
assert.equal(pollDelayFor(released), 2000);
|
||||
});
|
||||
819
src/api/ingest.ts
Normal file
819
src/api/ingest.ts
Normal file
@@ -0,0 +1,819 @@
|
||||
/**
|
||||
* The catalogue ingest service — `mcp.nearle.ai.in`.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* ── A drop is not a run ──────────────────────────────────────────────────────
|
||||
*
|
||||
* `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:
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* `resolveBatch` below makes that hop automatically, so callers poll one id and
|
||||
* get whichever record actually has the answer.
|
||||
*
|
||||
* ── No credential ────────────────────────────────────────────────────────────
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/* ── Limits, mirroring the service's own ──────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Checked here so a drop that cannot possibly be accepted is refused in the
|
||||
* browser rather than uploaded over a shop's connection to earn a 413. The
|
||||
* service remains the authority; this is politeness, not validation.
|
||||
*/
|
||||
export const MAX_FILES = 20;
|
||||
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
||||
/** Per file. A sheet over this is marked failed; the rest of the batch runs. */
|
||||
export const MAX_ROWS = 2000;
|
||||
|
||||
/** Everything the service parses, from the documented format list. */
|
||||
export const ACCEPTED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.tsv'];
|
||||
|
||||
/* ── Response types, from the owning team's documented output ─────────────── */
|
||||
|
||||
/**
|
||||
* Seven states, not four.
|
||||
*
|
||||
* `partial` and `interrupted` are the two that matter and the two a client is
|
||||
* most likely to collapse into something else. `interrupted` means a restart
|
||||
* cut the batch short; it never auto-restarts and needs an admin to resume it,
|
||||
* so reporting it as `failed` would send someone re-uploading a batch that is
|
||||
* 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'
|
||||
| 'partial'
|
||||
| 'failed'
|
||||
| 'interrupted'
|
||||
| 'cancelled';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
rows_total?: number;
|
||||
/** Can exceed `rows_total`: "100g, 200g, 500g" in one cell is three products. */
|
||||
products_built?: number;
|
||||
inserted?: number;
|
||||
/** Existing rows whose blank columns this upload filled in. */
|
||||
backfilled?: number;
|
||||
/** Already present and already complete — nothing to do. */
|
||||
skipped_existing?: number;
|
||||
rejected?: number;
|
||||
/** Sheet header → the field it was read as. */
|
||||
recognised_columns?: Record<string, string>;
|
||||
/** Headers that matched nothing. Reported, never an error. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* One stage a file has entered, from the run's own timeline.
|
||||
*
|
||||
* `finished_at: null` marks the stage running RIGHT NOW — that is how the
|
||||
* current position is found, not by trusting `stage_index` alone. The array
|
||||
* persists after the run ends, so a finished file can still show its whole
|
||||
* history; the scalars on the file only ever describe the present moment, which
|
||||
* is why they are not enough on their own.
|
||||
*/
|
||||
export interface BatchStage {
|
||||
index: number;
|
||||
name: string;
|
||||
rows_done?: number;
|
||||
rows_total?: number;
|
||||
/** Epoch SECONDS as a float, like every other timestamp here. */
|
||||
started_at?: number;
|
||||
finished_at?: number | null;
|
||||
}
|
||||
|
||||
export interface BatchFile {
|
||||
index: number;
|
||||
filename: string;
|
||||
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. */
|
||||
stage_index?: number;
|
||||
stage_name?: string;
|
||||
total_stages?: number;
|
||||
rows_done?: number;
|
||||
/** The stages this file has entered, oldest first. */
|
||||
stages?: BatchStage[];
|
||||
result?: BatchFileResult | null;
|
||||
}
|
||||
|
||||
export interface BatchTotals {
|
||||
rows_total: number;
|
||||
products_built: number;
|
||||
inserted: number;
|
||||
backfilled: number;
|
||||
skipped_existing: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface IngestBatch {
|
||||
batch_id: string;
|
||||
status: BatchStatus;
|
||||
detail: string | null;
|
||||
submitted_by?: string;
|
||||
/** Epoch SECONDS, not milliseconds — multiply before handing to `Date`. */
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
files_total: number;
|
||||
files_done: number;
|
||||
files_failed: number;
|
||||
current_file?: string | null;
|
||||
use_llm?: boolean;
|
||||
fetch_images?: boolean;
|
||||
/**
|
||||
* All eleven stage names, in order.
|
||||
*
|
||||
* Served rather than left for us to hardcode, deliberately — draw the
|
||||
* pipeline from this and the console cannot drift out of step when a stage is
|
||||
* added or renamed on their side.
|
||||
*/
|
||||
stage_names?: string[];
|
||||
/**
|
||||
* Who is executing the batch.
|
||||
*
|
||||
* `"dagster"` is a silent failure in production and has to be surfaced rather
|
||||
* than rendered as progress. Dagster is a local development orchestrator — it
|
||||
* is absent from the deployed image, which never copies `orchestration/` — so
|
||||
* a batch staged for it is handed to nobody and parks at `queued` forever
|
||||
* saying "Waiting for the Dagster orchestrator to pick this batch up". From
|
||||
* outside that is indistinguishable from a hang, and the fix is not to wait:
|
||||
* an admin resumes it onto the in-process worker.
|
||||
*/
|
||||
runner?: 'inprocess' | 'dagster' | string;
|
||||
totals: BatchTotals;
|
||||
/** The brands this batch touched — the way back into the catalogue view. */
|
||||
brands: string[];
|
||||
files: BatchFile[];
|
||||
/** Present only on the POST response; the polling reads omit it. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class IngestError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
|
||||
constructor(message: string, status: number, body = '') {
|
||||
super(message);
|
||||
this.name = 'IngestError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Requests ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SubmitOptions {
|
||||
files: File[];
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
sender?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a drop the service is certain to reject, and says which file is at
|
||||
* fault rather than reporting the batch as generically too large.
|
||||
*/
|
||||
function guardFiles(files: File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new IngestError('Choose at least one file.', 400);
|
||||
}
|
||||
if (files.length > MAX_FILES) {
|
||||
throw new IngestError(
|
||||
`That is ${files.length} files. The service takes ${MAX_FILES} per upload — send them in smaller batches.`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size === 0) {
|
||||
throw new IngestError(`"${file.name}" is empty.`, 400);
|
||||
}
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
throw new IngestError(
|
||||
`"${file.name}" is ${(file.size / 1024 / 1024).toFixed(1)} MB. The limit is 10 MB per file.`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* The FILENAME picks the parser, not the bytes. A name the service does not
|
||||
* recognise comes back as an unexplained parse failure, so it is named here
|
||||
* instead.
|
||||
*/
|
||||
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
|
||||
if (!file.name.includes('.') || !ACCEPTED_EXTENSIONS.includes(extension)) {
|
||||
throw new IngestError(
|
||||
`"${file.name}" is not a format the service reads. Accepted: ${ACCEPTED_EXTENSIONS.join(', ')}.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const total = files.reduce((sum, file) => sum + file.size, 0);
|
||||
if (total > MAX_TOTAL_BYTES) {
|
||||
throw new IngestError(
|
||||
`That is ${(total / 1024 / 1024).toFixed(1)} MB in total. The limit is 50 MB per upload.`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the sheets. Answers 202 with a batch to poll — it does not wait.
|
||||
*
|
||||
* The form field is `files` and it is REPEATED, once per file. The older
|
||||
* endpoint took a single `file`, and sending that name here parses as no files
|
||||
* at all.
|
||||
*/
|
||||
export async function submitBatch(options: SubmitOptions): Promise<IngestBatch> {
|
||||
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);
|
||||
|
||||
// `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}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
// Content-Type is deliberately unset: the browser adds it WITH the
|
||||
// multipart boundary. Setting it by hand omits the boundary and the
|
||||
// server parses nothing.
|
||||
headers: { Accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new IngestError(
|
||||
cause instanceof DOMException && cause.name === 'AbortError'
|
||||
? 'Cancelled.'
|
||||
: 'Could not reach the ingest service.',
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return readResponse<IngestBatch>(response);
|
||||
}
|
||||
|
||||
/** One poll. */
|
||||
export async function fetchBatch(batchId: string, signal?: AbortSignal): Promise<IngestBatch> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${INGEST_BASE}${ROOT}/${encodeURIComponent(batchId)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} catch {
|
||||
throw new IngestError('Lost contact with the ingest service while waiting.', 0);
|
||||
}
|
||||
return readResponse<IngestBatch>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The products a finished run wrote, optionally narrowed to our own files.
|
||||
*
|
||||
* `filenames` is not optional in practice and should always be passed. An admin
|
||||
* can assemble ONE run from several drops — the owning team's own words: "a run
|
||||
* an admin assembled from several drops lists every file in it, so you may see
|
||||
* filenames batched alongside your own" — so a run's manifest can contain other
|
||||
* senders' products.
|
||||
*
|
||||
* Reading all of them was a real hazard, not a tidiness point. The sheet's price
|
||||
* and opening stock are applied to whatever the manifest is matched against, so
|
||||
* a product from someone else's sheet sharing a name with one of our rows would
|
||||
* have been priced and stocked from OUR file, into OUR merchant's branch.
|
||||
*
|
||||
* Filtering by filename is the best this contract allows and it is not airtight:
|
||||
* two senders can both upload `products.csv`. Narrowing by drop would be exact,
|
||||
* and the run's files carry no drop reference to narrow by — worth asking for.
|
||||
*/
|
||||
export function productsOf(batch: IngestBatch, filenames?: readonly string[]): IngestProduct[] {
|
||||
const wanted = filenames ? new Set(filenames) : null;
|
||||
return (batch.files ?? [])
|
||||
.filter((file) => !wanted || wanted.has(file.filename))
|
||||
.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 {
|
||||
// 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'
|
||||
);
|
||||
}
|
||||
|
||||
/** Sleeps, unless the caller aborts first. */
|
||||
function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(finish, ms);
|
||||
function finish() {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', finish);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener('abort', finish, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves the moment the tab is visible again — immediately if it already is. */
|
||||
function whenVisible(signal?: AbortSignal): Promise<void> {
|
||||
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
if (document.visibilityState !== 'visible' && !signal?.aborted) return;
|
||||
document.removeEventListener('visibilitychange', finish);
|
||||
signal?.removeEventListener('abort', finish);
|
||||
resolve();
|
||||
};
|
||||
document.addEventListener('visibilitychange', finish);
|
||||
signal?.addEventListener('abort', finish, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** How long to wait before the next reading, given where the batch has got to. */
|
||||
export function pollDelayFor(batch: IngestBatch): number {
|
||||
// A run is minutes and a person is watching the stage name move.
|
||||
if (!isAwaitingReview(batch)) return 2000;
|
||||
// A review hold is however long their admin takes — sometimes hours. Two
|
||||
// seconds against that is 1,800 requests an hour to be told "still waiting".
|
||||
return 15000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls until the batch is finished, THROUGH the review hold.
|
||||
*
|
||||
* ── Why it no longer stops at "awaiting review" ─────────────────────────────
|
||||
*
|
||||
* It used to return there, on the reasoning that waiting for an admin is not
|
||||
* progress. True, but it left the console showing "waiting for review" forever
|
||||
* once that admin released the drop — the steps only moved when the operator
|
||||
* reloaded the page, which is the one thing a step-by-step progress panel is
|
||||
* supposed to save them from. The release is exactly the transition worth
|
||||
* watching: it is when the drop becomes a run and the products start arriving.
|
||||
*
|
||||
* So the hold is polled too, at `pollDelayFor`'s slower cadence — 15s rather
|
||||
* than 2s, because a hold can last hours and a person is not watching a bar
|
||||
* during one.
|
||||
*
|
||||
* ── And why a hidden tab costs nothing ──────────────────────────────────────
|
||||
*
|
||||
* Polling pauses entirely while the tab is in the background and takes a
|
||||
* reading the instant it comes forward. So a sheet left open in another tab all
|
||||
* afternoon makes no requests, and is up to date by the time the operator has
|
||||
* looked at it — which is the same thing they used to get from reloading, minus
|
||||
* the reload.
|
||||
*
|
||||
* `onTick` fires on each reading so the caller renders stage names and row
|
||||
* counts as they move. It stops on a result and on a dismissal: a declined drop
|
||||
* will never produce one.
|
||||
*/
|
||||
export async function pollBatch(
|
||||
batchId: string,
|
||||
onTick: (batch: IngestBatch) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<IngestBatch> {
|
||||
for (;;) {
|
||||
if (signal?.aborted) throw new IngestError('Cancelled.', 0);
|
||||
|
||||
// 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) || isDismissed(batch)) return batch;
|
||||
|
||||
await wait(pollDelayFor(batch), signal);
|
||||
await whenVisible(signal);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reading a response ───────────────────────────────────────────────────── */
|
||||
|
||||
async function readResponse<T>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
|
||||
if (!response.ok) throw describe(response.status, payload, text);
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* `detail` is a STRING on some failures and an OBJECT on others.
|
||||
*
|
||||
* 400 and 413 send a sentence; 422 sends `{message, rows_total, errors[]}`.
|
||||
* Rendering it straight prints "[object Object]" for exactly the response that
|
||||
* carries the most useful information, so both shapes are unpacked here.
|
||||
*/
|
||||
function detailOf(payload: unknown): string | undefined {
|
||||
if (payload === null || typeof payload !== 'object') return undefined;
|
||||
const detail = (payload as { detail?: unknown }).detail;
|
||||
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (detail !== null && typeof detail === 'object') {
|
||||
const nested = detail as { message?: unknown; errors?: unknown };
|
||||
const message = typeof nested.message === 'string' ? nested.message : undefined;
|
||||
const errors = Array.isArray(nested.errors) ? nested.errors : [];
|
||||
// Row numbers are what makes a 422 actionable — they are the sheet's own
|
||||
// 1-based numbering, header included, so they match what the operator sees.
|
||||
const rows = errors
|
||||
.slice(0, 5)
|
||||
.map((entry) => {
|
||||
const row = (entry as { row?: unknown }).row;
|
||||
const error = (entry as { error?: unknown }).error;
|
||||
return `row ${String(row)}: ${String(error)}`;
|
||||
})
|
||||
.join(' · ');
|
||||
return [message, rows].filter(Boolean).join(' — ') || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service's failures, in words that name the fix.
|
||||
*
|
||||
* Each of these has one cause and one remedy, and a generic "request failed"
|
||||
* sends people to look at their spreadsheet for a problem that is in the
|
||||
* deployment.
|
||||
*/
|
||||
function describe(status: number, payload: unknown, text: string): IngestError {
|
||||
const detail = detailOf(payload);
|
||||
const body = text.slice(0, 2000);
|
||||
|
||||
if (status === 401) {
|
||||
// Deliberately NOT `detail ?? …`: the service answers both a missing key
|
||||
// and a malformed one with a flat "Invalid API key.", which is true and
|
||||
// tells nobody what to change.
|
||||
return new IngestError(
|
||||
'The ingest service rejected the credential. INGEST_TOKEN must be the SECRET ONLY — the 43-character value, not the `name:role:secret` triple, which fails as an invalid key rather than a malformed one. Set it on the container and restart; envsubst runs at container start, so a running container will not pick it up.',
|
||||
status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
if (status === 403) {
|
||||
return new IngestError(
|
||||
detail ??
|
||||
'That key authenticated but does not hold `upload_catalog`. It needs the `uploader` or `admin` role.',
|
||||
status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
if (status === 413) {
|
||||
return new IngestError(
|
||||
detail ?? 'Too large for the service — 20 files, 10 MB each, 50 MB and 20,000 rows per upload.',
|
||||
status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
if (status === 429) {
|
||||
return new IngestError(
|
||||
detail ??
|
||||
'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,
|
||||
);
|
||||
}
|
||||
if (status === 400) {
|
||||
return new IngestError(
|
||||
detail ??
|
||||
'The service could not read that file. Every sheet needs a product-name column — product, item, variant or name.',
|
||||
status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
return new IngestError(detail ?? `The ingest service returned HTTP ${status}.`, status, body);
|
||||
}
|
||||
|
||||
/* ── Reading a finished batch ─────────────────────────────────────────────── */
|
||||
|
||||
/** True when the batch ended without everything landing. */
|
||||
export function isIncomplete(batch: IngestBatch): boolean {
|
||||
return (
|
||||
batch.status === 'partial' ||
|
||||
batch.status === 'failed' ||
|
||||
batch.status === 'interrupted' ||
|
||||
batch.status === 'cancelled' ||
|
||||
batch.files_failed > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** One line for the top of the result panel. */
|
||||
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.';
|
||||
}
|
||||
if (batch.status === 'interrupted') {
|
||||
return 'The service restarted part-way through. An admin can resume this batch — it will not restart on its own.';
|
||||
}
|
||||
if (batch.status === 'cancelled') {
|
||||
return 'This batch was cancelled before every file ran.';
|
||||
}
|
||||
|
||||
const parts = [`${totals?.inserted ?? 0} added`];
|
||||
if ((totals?.backfilled ?? 0) > 0) parts.push(`${totals.backfilled} filled in`);
|
||||
if ((totals?.skipped_existing ?? 0) > 0) parts.push(`${totals.skipped_existing} already there`);
|
||||
if ((totals?.rejected ?? 0) > 0) parts.push(`${totals.rejected} rejected`);
|
||||
|
||||
const summary = parts.join(' · ');
|
||||
return batch.files_failed > 0
|
||||
? `${summary} — but ${batch.files_failed} of ${batch.files_total} files could not be read`
|
||||
: summary;
|
||||
}
|
||||
|
||||
/** Overall progress, for a bar. Stages within a file are too fine to show. */
|
||||
export function progressOf(batch: IngestBatch): { done: number; total: number } {
|
||||
return { done: batch.files_done + batch.files_failed, total: batch.files_total };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the batch was handed to an orchestrator that is not there.
|
||||
*
|
||||
* `runner: "dagster"` never runs in production: Dagster is a development tool,
|
||||
* absent from the deployed image, so the batch waits for a worker that will
|
||||
* never claim it and sits at `queued` indefinitely. It has to be named, because
|
||||
* every visible signal — a queued status, a stage index of 0, a progress bar at
|
||||
* nothing — is identical to a batch that is merely waiting its turn.
|
||||
*
|
||||
* Only meaningful while it is still waiting. A batch that reached `running`
|
||||
* plainly found an executor whatever it was staged for.
|
||||
*/
|
||||
export function isStuckOnMissingRunner(batch: IngestBatch): boolean {
|
||||
return batch.runner === 'dagster' && (batch.status === 'queued' || batch.status === 'pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a file is in the eleven stages, read from the timeline rather than the
|
||||
* scalars.
|
||||
*
|
||||
* `stages[]` is the authority: the entry with `finished_at: null` is the stage
|
||||
* running now. `stage_index`/`stage_name` describe the same moment and are used
|
||||
* as a fallback for a service build that does not send the timeline, but they
|
||||
* cannot show a finished file's history and the timeline can.
|
||||
*
|
||||
* Returns null when there is nothing to draw — a file that has not started, or
|
||||
* one from a response carrying neither.
|
||||
*/
|
||||
export function currentStage(file: BatchFile): BatchStage | null {
|
||||
const running = (file.stages ?? []).find((stage) => stage.finished_at == null);
|
||||
if (running) return running;
|
||||
|
||||
// Finished, or a build without the timeline. The last entered stage is the
|
||||
// most useful thing to show for a file that has stopped moving.
|
||||
const last = (file.stages ?? []).at(-1);
|
||||
if (last) return last;
|
||||
|
||||
if (!file.stage_index) return null;
|
||||
return {
|
||||
index: file.stage_index,
|
||||
name: file.stage_name ?? `Stage ${file.stage_index}`,
|
||||
...(file.rows_done === undefined ? {} : { rows_done: file.rows_done }),
|
||||
...(file.rows_total === undefined ? {} : { rows_total: file.rows_total }),
|
||||
};
|
||||
}
|
||||
262
src/api/insights.ts
Normal file
262
src/api/insights.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Order, delivery and POS reads — the numbers behind store performance.
|
||||
*
|
||||
* Note the split: online orders come from `/web/orders/*` and counter sales
|
||||
* from `/pos/*`, which is the ONLY part of the API carrying auth middleware.
|
||||
* Any figure that blends the two is eventually consistent by construction, so
|
||||
* screens that show one must also show its freshness.
|
||||
*/
|
||||
|
||||
import { api, POS, WEB } from './client';
|
||||
import type {
|
||||
DeliveryRow,
|
||||
DeliverySummary,
|
||||
LocationOrderSummary,
|
||||
OrderItem,
|
||||
OrderRow,
|
||||
OrderSummary,
|
||||
PosLocationHealth,
|
||||
PosTerminalHealth,
|
||||
PosSalesPage,
|
||||
PosSalesSummary,
|
||||
} from './types';
|
||||
|
||||
export interface DateRange {
|
||||
fromdate?: string;
|
||||
todate?: string;
|
||||
}
|
||||
|
||||
export interface OrderQuery extends DateRange {
|
||||
tenantid: number;
|
||||
/** Omit for every branch of the tenant. */
|
||||
locationid?: number;
|
||||
/**
|
||||
* One delivery partner's work, ACROSS every merchant they serve.
|
||||
*
|
||||
* The platform's own view of dispatch: a partner's riders carry for many
|
||||
* shops at once — partner 60 answered with 376 deliveries spanning 12
|
||||
* merchants — and no tenant-scoped read can show that. Verified live on
|
||||
* 2026-09-11.
|
||||
*
|
||||
* Never sent alongside a tenantid. The endpoint treats the two as separate
|
||||
* doors onto the same table, not as filters that combine.
|
||||
*/
|
||||
partnerid?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
|
||||
export const insightsApi = {
|
||||
/**
|
||||
* The order rows themselves.
|
||||
*
|
||||
* `/orders/tenant/getorders` rather than the bare `/orders/getorders`: the
|
||||
* controller routes on which ids are present, and passing a tenant with no
|
||||
* partner, customer or app-user reaches `GetTenantOrders`. Passing a
|
||||
* `locationid` as well reaches `GetTenantLocationOrders`, which is the
|
||||
* branch-scoped read — so one call covers both "all branches" and "one
|
||||
* branch" by presence alone.
|
||||
*
|
||||
* `pageno` is 1-based here. The controller floors anything <= 0 to 1, so
|
||||
* sending 0 silently gives page one rather than an error.
|
||||
*/
|
||||
orders: (query: OrderQuery) =>
|
||||
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
|
||||
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
keyword: query.keyword,
|
||||
fromdate: query.fromdate,
|
||||
todate: query.todate,
|
||||
pageno: query.pageno ?? 1,
|
||||
pagesize: query.pagesize ?? 50,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Every order in a window, not the first page of them.
|
||||
*
|
||||
* Reports totals its figures from order ROWS — `getlocationsummary` carries no
|
||||
* money and ignores the date picker, so the rows are the only source that both
|
||||
* has revenue and respects the range. Reducing over a single `pagesize: 500`
|
||||
* read made every one of those figures a silent lie the moment a tenant traded
|
||||
* more than five hundred orders in the window: the page showed a total, gave no
|
||||
* sign it was a partial one, and `api.list` discards the envelope so nothing
|
||||
* downstream could even detect the cut.
|
||||
*
|
||||
* Paging stops on a SHORT PAGE rather than on a count the list endpoint does
|
||||
* not return — the same rule `catalogue.idsByImageId` follows, and for the same
|
||||
* reason: a total we would have to trust is worse than a page we can measure.
|
||||
*
|
||||
* `maxPages` is a real bound, not a formality. Something has to stop a loop
|
||||
* pointed at production, and a window wide enough to exceed it is a window the
|
||||
* reader should be told about rather than one we quietly keep fetching. Hence
|
||||
* `truncated`, which the caller is expected to surface — the whole point of
|
||||
* this function is that a partial total never again passes for a complete one.
|
||||
*/
|
||||
ordersAll: async (
|
||||
query: OrderQuery,
|
||||
{ pagesize = 500, maxPages = 10 }: { pagesize?: number; maxPages?: number } = {},
|
||||
): Promise<{ rows: OrderRow[]; truncated: boolean }> => {
|
||||
const rows: OrderRow[] = [];
|
||||
/* `pageno` is 1-based on this endpoint — the controller floors <= 0 to 1. */
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
const batch = await insightsApi.orders({ ...query, pageno: page, pagesize });
|
||||
rows.push(...batch);
|
||||
if (batch.length < pagesize) return { rows, truncated: false };
|
||||
}
|
||||
return { rows, truncated: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* The delivery jobs.
|
||||
*
|
||||
* A separate read from the orders list, NOT a filter over it. The rows are a
|
||||
* different struct with different fields — rider name, planned vs actual
|
||||
* distance, rider charge vs job value, notes — and a different status ladder.
|
||||
* Deriving deliveries from orders, which is what this page did first, loses
|
||||
* every one of those.
|
||||
*
|
||||
* The controller 400s unless one of tenantid/partnerid/customerid/
|
||||
* applocationid/userid/appuserid is present, so `tenantid` is required here.
|
||||
*/
|
||||
deliveries: (query: OrderQuery) =>
|
||||
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
|
||||
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
keyword: query.keyword,
|
||||
fromdate: query.fromdate,
|
||||
todate: query.todate,
|
||||
pageno: query.pageno ?? 1,
|
||||
pagesize: query.pagesize ?? 50,
|
||||
}),
|
||||
|
||||
orderSummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<OrderSummary>(`${WEB}/orders/getordersummary`, { tenantid, ...range }),
|
||||
|
||||
/** Per-branch order totals for one tenant. `tenantid` is required. */
|
||||
locationSummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.list<LocationOrderSummary>(`${WEB}/orders/getlocationsummary`, { tenantid, ...range }),
|
||||
|
||||
revenueSummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
|
||||
|
||||
/**
|
||||
* `granularity` is REQUIRED and was never sent, so this call answered 400
|
||||
* every single time: "granularity query parameter is required (day, month,
|
||||
* year)". Nothing renders it yet, which is the only reason it went unnoticed
|
||||
* — the first screen to use it would have shown an error instead of a chart.
|
||||
*
|
||||
* Defaulted rather than made a required argument: a day-by-day series is what
|
||||
* every caller of a dated range wants, and a parameter with one sensible
|
||||
* answer should not be every caller's problem.
|
||||
*/
|
||||
timeSeries: (
|
||||
tenantid: number,
|
||||
range: DateRange = {},
|
||||
granularity: 'day' | 'month' | 'year' = 'day',
|
||||
) =>
|
||||
api.get<Record<string, unknown>[]>(`${WEB}/orders/gettimeseries`, {
|
||||
tenantid,
|
||||
granularity,
|
||||
...range,
|
||||
}),
|
||||
|
||||
/**
|
||||
* What is actually IN an order.
|
||||
*
|
||||
* The only read that carries line items. Every list endpoint returns an
|
||||
* order's totals and never its contents, which is why the detail sheet could
|
||||
* say an order was worth ₹840 and not what the ₹840 bought.
|
||||
*
|
||||
* The envelope rather than `api.list`, because the authoritative total lives
|
||||
* outside `details`: `OrderDetail.Orderamount` is `json:"-"` on the server, so
|
||||
* `pricedetails.orderamount` is the only place it appears. Summing the lines
|
||||
* would be recomputing a figure Fiesta has already worked out, and the two
|
||||
* would disagree the first time a discount rounded differently.
|
||||
*/
|
||||
orderItems: async (orderheaderid: number) => {
|
||||
const envelope = await api.envelope<OrderItem[]>(`${WEB}/orders/getorderdetails`, {
|
||||
params: { orderheaderid },
|
||||
});
|
||||
return {
|
||||
// `details: null` for an order with no lines is as common here as `[]`;
|
||||
// see the note on `api.list`.
|
||||
items: envelope.details ?? [],
|
||||
amount: envelope.pricedetails?.orderamount ?? 0,
|
||||
tax: envelope.pricedetails?.totaltaxamount ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
deliverySummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),
|
||||
|
||||
/**
|
||||
* Counter sales for ONE outlet.
|
||||
*
|
||||
* `locationid` is required and singular — there is no tenant-wide POS call,
|
||||
* so a multi-branch view fans out one request per branch.
|
||||
*/
|
||||
posSales: (
|
||||
locationid: number,
|
||||
params: DateRange & {
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
/** The three server-side filters the old console's bills tab offers. */
|
||||
terminalid?: string;
|
||||
cashiername?: string;
|
||||
paymentmode?: string;
|
||||
} = {},
|
||||
) =>
|
||||
api.get<PosSalesPage>(`${POS}/sales`, {
|
||||
locationid,
|
||||
pageno: params.pageno ?? 0,
|
||||
pagesize: params.pagesize ?? 50,
|
||||
fromdate: params.fromdate,
|
||||
todate: params.todate,
|
||||
terminalid: params.terminalid,
|
||||
cashiername: params.cashiername,
|
||||
paymentmode: params.paymentmode,
|
||||
}),
|
||||
|
||||
/**
|
||||
* One counter bill in full, with its lines.
|
||||
*
|
||||
* `reference` accepts the till's order UUID, the invoice number, or this
|
||||
* backend's posorderid — a support call starts from whichever the person is
|
||||
* looking at, so the endpoint takes all three.
|
||||
*/
|
||||
posSaleDetail: (locationid: number, reference: string) =>
|
||||
api.get<unknown>(`${POS}/sales/detail`, { locationid, reference }),
|
||||
|
||||
/**
|
||||
* Counter-sales totals for ONE outlet.
|
||||
*
|
||||
* The richest read in the API: bill count, gross, tax, discount, roundoff and
|
||||
* average bill, already broken down by payment mode, by day and by terminal.
|
||||
* Reports gets its offline half from this and nothing else.
|
||||
*
|
||||
* Kept apart from the order summaries above on purpose. These are `posorders`
|
||||
* rows; those are `orders` rows. The two are never added together — see
|
||||
* `store-admin-backend-gap.md` §3.1 for why that would double-count a branch
|
||||
* that both runs a till and uploads a spreadsheet.
|
||||
*/
|
||||
posSalesSummary: (locationid: number, range: DateRange = {}) =>
|
||||
api.get<PosSalesSummary>(`${POS}/sales/summary`, { locationid, ...range }),
|
||||
|
||||
/**
|
||||
* Till presence for one outlet — how many are online, how many bills are stranded.
|
||||
*
|
||||
* Returns the terminal list, not the wrapper. The endpoint answers
|
||||
* `{location_id, total, online, terminals}`; every caller wants `terminals`,
|
||||
* and `summariseBranch` recomputes `online` from the heartbeats anyway
|
||||
* because Fiesta's figure counts a stale till as present. Unwrapping here
|
||||
* keeps that one shape fact in the API layer instead of on every page.
|
||||
*/
|
||||
posHealth: (locationid: number): Promise<PosTerminalHealth[]> =>
|
||||
api
|
||||
.get<PosLocationHealth>(`${POS}/health/location`, { location_id: locationid })
|
||||
.then((health) => (Array.isArray(health?.terminals) ? health.terminals : [])),
|
||||
};
|
||||
166
src/api/nutrition.ts
Normal file
166
src/api/nutrition.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Health scores and nutrition, from the catalogue-intelligence service.
|
||||
*
|
||||
* A SEPARATE HOST from Fiesta — `mcp.nearle.ai.in`, the same service that
|
||||
* scrapes the global catalogue — so it does not go through `client.ts`, which
|
||||
* exists to talk to one backend. It is read-only and unauthenticated, like the
|
||||
* catalogue reads beside it.
|
||||
*
|
||||
* ── The join key ────────────────────────────────────────────────────────────
|
||||
*
|
||||
* `image_id`, not `catalogueid`. That is the same stable key the catalogue
|
||||
* import already uses, and for the same reason: `catalogueid` is renumbered on
|
||||
* every re-scrape, so a link made through it goes stale silently. A product
|
||||
* carries its `imageid` from the import, and that is what resolves here.
|
||||
*
|
||||
* ── Two things measured against the live service, 4 Sep 2026 ────────────────
|
||||
*
|
||||
* - `include_unknown=true` is REQUIRED or the list returns nothing. With it,
|
||||
* 252 items; without it, zero — including products whose `data_status` is
|
||||
* "verified" and whose score is a real number. The flag reads like it should
|
||||
* only add unscored rows; in practice its absence removes everything.
|
||||
*
|
||||
* - Scoring covers ten brands (Nestle, Amul, Coca-Cola, Cadbury and six
|
||||
* smaller ones). None of the brands our merchants actually stock are among
|
||||
* them yet, so today this renders on no products at all. The wiring is
|
||||
* correct; the data has to catch up.
|
||||
*/
|
||||
|
||||
const NUTRITION_BASE = 'https://mcp.nearle.ai.in/api';
|
||||
|
||||
/** How confident the service is that it matched the right source record. */
|
||||
export const LOW_CONFIDENCE = 0.7;
|
||||
|
||||
export interface NutritionScore {
|
||||
brand?: string;
|
||||
image_id?: string;
|
||||
product_name?: string;
|
||||
category?: string;
|
||||
|
||||
/** 0–100. `null` when the product is known but has not been scored. */
|
||||
health_score?: number | null;
|
||||
nutrition_score?: number | null;
|
||||
health_band?: string | null;
|
||||
scoring_version?: string | null;
|
||||
|
||||
/** Sentences, already written for a person. Rendered as given. */
|
||||
positive_insights?: string[];
|
||||
nutritional_cautions?: string[];
|
||||
ai_summary?: string | null;
|
||||
|
||||
diet_tags?: string[];
|
||||
allergens?: string[];
|
||||
|
||||
/** "verified" when the source record was confirmed. */
|
||||
data_status?: string | null;
|
||||
data_source?: string | null;
|
||||
source_url?: string | null;
|
||||
/**
|
||||
* 0–1. The 5 Star record scores 0.577 — a moderate match, not a certainty.
|
||||
*
|
||||
* Surfaced rather than hidden. A nutrition panel presented as fact when the
|
||||
* underlying match is a guess is worse than no panel, and that goes double
|
||||
* for the allergen list.
|
||||
*/
|
||||
match_confidence?: number | null;
|
||||
|
||||
serving_size_g?: number | null;
|
||||
serving_size_label?: string | null;
|
||||
calories_kcal?: number | null;
|
||||
protein_g?: number | null;
|
||||
carbohydrates_g?: number | null;
|
||||
total_sugar_g?: number | null;
|
||||
added_sugar_g?: number | null;
|
||||
dietary_fiber_g?: number | null;
|
||||
total_fat_g?: number | null;
|
||||
saturated_fat_g?: number | null;
|
||||
sodium_mg?: number | null;
|
||||
}
|
||||
|
||||
async function read<T>(path: string): Promise<T | null> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${NUTRITION_BASE}${path}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
} catch {
|
||||
// A nutrition panel is an enhancement on a product page. If the service is
|
||||
// unreachable the page still has to render, so this reports "nothing"
|
||||
// rather than throwing into the drawer.
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) return null;
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Their brand spelling, resolved from ours.
|
||||
*
|
||||
* The two catalogues agree on every brand and disagree on how to write it:
|
||||
*
|
||||
* ours theirs
|
||||
* cadbury → Cadbury
|
||||
* coca_cola → Coca-Cola underscore becomes a HYPHEN
|
||||
* brooke_bond → Brooke Bond underscore becomes a SPACE
|
||||
* 24_mantra → 24 Mantra
|
||||
*
|
||||
* Which separator an underscore becomes cannot be derived — it is a hyphen for
|
||||
* Coca-Cola and Colgate-Palmolive and a space for everything else. So the list
|
||||
* is fetched and matched on a normalised form rather than guessed at.
|
||||
*
|
||||
* This is not cosmetic. `GET /nutrition/cadbury/...` returns
|
||||
* `health_score: null` — a well-formed answer meaning "no score", not an error
|
||||
* — so getting the case wrong looks exactly like a product nobody has scored,
|
||||
* on every product, forever.
|
||||
*
|
||||
* Cached for the process: a brand list changes when the scraper learns a new
|
||||
* brand, which is not during a session.
|
||||
*/
|
||||
let brandsPromise: Promise<string[]> | null = null;
|
||||
|
||||
function normalise(brand: string): string {
|
||||
return brand.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
async function resolveBrand(raw: string): Promise<string> {
|
||||
const wanted = normalise(raw);
|
||||
if (!wanted) return raw;
|
||||
|
||||
brandsPromise ??= read<{ brands?: string[] }>('/brands').then((r) => r?.brands ?? []);
|
||||
const brands = await brandsPromise;
|
||||
|
||||
// Their exact spelling if we know it; ours unchanged if we do not, so a brand
|
||||
// they have not listed still gets a real attempt rather than being dropped.
|
||||
return brands.find((candidate) => normalise(candidate) === wanted) ?? raw;
|
||||
}
|
||||
|
||||
export const nutritionApi = {
|
||||
/**
|
||||
* One product's score and nutrition.
|
||||
*
|
||||
* Returns null when the product is unknown to the service, and a record with
|
||||
* `health_score: null` when it is known but unscored — two different answers
|
||||
* that must not be collapsed, because the second means "coming soon" and the
|
||||
* first means "this product was never in the catalogue".
|
||||
*/
|
||||
forProduct: async (brand: string, imageId: string) => {
|
||||
// Resolved first: our catalogue spells brands in snake_case and theirs does
|
||||
// not, and the mismatch reads as "unscored" rather than as an error.
|
||||
const resolved = await resolveBrand(brand);
|
||||
return read<NutritionScore>(
|
||||
`/nutrition/${encodeURIComponent(resolved)}/${encodeURIComponent(imageId)}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Exposed for the check script, which asserts the two vocabularies still line up. */
|
||||
export const __resolveBrand = resolveBrand;
|
||||
|
||||
/** True when the service knows the product but has not scored it yet. */
|
||||
export function isUnscored(score: NutritionScore | null): boolean {
|
||||
return score !== null && (score.health_score === null || score.health_score === undefined);
|
||||
}
|
||||
244
src/api/optimiser.ts
Normal file
244
src/api/optimiser.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { SolverRequest, Tuning } from '@/features/store-admin/autoAssign';
|
||||
import type { OrderRow } from './types';
|
||||
|
||||
/**
|
||||
* The route optimiser.
|
||||
*
|
||||
* A SEPARATE SERVICE from Fiesta — `routes.workolik.com`, "Route Optimization
|
||||
* API v2.0.0" — so it does not go through `client.ts`, which exists to talk to
|
||||
* one backend. Road routing is real (a Valhalla backend, not straight lines)
|
||||
* and the assignment model is trained: 3,627 records, tuned to 20 orders per
|
||||
* rider and an ideal load of 4.
|
||||
*
|
||||
* ── What it is and is not ───────────────────────────────────────────────────
|
||||
*
|
||||
* `optimization/createdeliveries` is NOT a create, despite the name it shares
|
||||
* with Fiesta's. It is a pure function: send an array of orders, get the same
|
||||
* array back reordered nearest-neighbour with `step`, `previouskms`,
|
||||
* `cumulativekms`, `actualkms` and `eta` added. Its own docs say forwarding is
|
||||
* paused, and the verified behaviour matches — it writes nothing anywhere.
|
||||
*
|
||||
* So the sequence is ours to commit: we take its answer and post it to Fiesta's
|
||||
* `deliveries/createdeliveries` ourselves. That is also what the xpress console
|
||||
* does, which is the only reason its two identically-named endpoints do not
|
||||
* collide.
|
||||
*
|
||||
* ── `riderassign` assigns against OUR fleet, not a foreign one ──────────────
|
||||
*
|
||||
* This file used to say the opposite — that `riderassign` was useless because
|
||||
* it returned orders assigned to `rider_id 883, "Rajan A"`, "not one of ours".
|
||||
* That was wrong, and it was wrong for the ordinary reason: an unfamiliar id
|
||||
* was taken for a stranger without checking the roster.
|
||||
*
|
||||
* Checked on 2026-09-10. `getriderroster?partnerid=44` lists 883 "Rajan A", and
|
||||
* so do the rider ids on tenant 916's own delivery rows — 883, 897, 950, 1111,
|
||||
* 1114, every one of them partner 44's, which is the Coimbatore fleet. The
|
||||
* solver reads the same database Fiesta does: `getallriders` on jupiter and
|
||||
* `getriders` on Fiesta return identical rosters and identical on-duty state.
|
||||
*
|
||||
* So auto-assignment works and `assign` below wires it up.
|
||||
*
|
||||
* ── What it cannot do yet, and why that is not our bug ──────────────────────
|
||||
*
|
||||
* The solver picks the riders itself, gated on `onduty = 1`, and that flag is 0
|
||||
* for all 118 riders on the platform — every region, checked the same day. So
|
||||
* `active_riders_pool` is 0 and every order comes back unassigned with "No
|
||||
* riders found (check partner online status)". Supplying riders in the body
|
||||
* does not help: `riders` and `active_riders` were both tried against a rider
|
||||
* the on-duty endpoint DOES report, and the pool stayed 0.
|
||||
*
|
||||
* Whatever is meant to set `onduty` is not setting it. That is worth asking the
|
||||
* app team about; nothing here can work around it.
|
||||
*
|
||||
* ── `routemate` is gone ─────────────────────────────────────────────────────
|
||||
*
|
||||
* The old console's second mode posted to `routemate.workolik.com/api/v1/
|
||||
* optimization/riderassign?strategy=multi_trip`, which accepted a rider list
|
||||
* inline. It answers 404 now, with and without the query string, so that route
|
||||
* around the `onduty` gate is closed too.
|
||||
*/
|
||||
|
||||
const OPTIMISER_BASE = 'https://routes.workolik.com/api/v1';
|
||||
|
||||
/** A solve can legitimately take a while. Past this, something is wrong. */
|
||||
const SOLVE_TIMEOUT_MS = 90_000;
|
||||
|
||||
/** An order as the optimiser hands it back — ours, plus the routing it added. */
|
||||
export interface SequencedStop extends OrderRow {
|
||||
/** 1..N. The order to visit in. */
|
||||
step?: number;
|
||||
/** Kilometres from the previous stop. */
|
||||
previouskms?: number;
|
||||
/** Running total for the round. */
|
||||
cumulativekms?: number;
|
||||
/**
|
||||
* Direct pickup-to-delivery distance, as a string.
|
||||
*
|
||||
* The service returns these as strings ("1.23"), which is also how Fiesta's
|
||||
* `deliveries.kms` / `actualkms` columns are typed — so they carry across
|
||||
* unconverted. Those columns are exactly the ones found holding the literal
|
||||
* text "null" in production, which broke the rider summary; a sequence run is
|
||||
* what should be filling them with real numbers.
|
||||
*/
|
||||
actualkms?: string;
|
||||
kms?: string;
|
||||
/** Minutes for this leg, and cumulative. Strings, as sent. */
|
||||
eta?: string;
|
||||
cumulative_eta?: string;
|
||||
ordertype?: string;
|
||||
}
|
||||
|
||||
/** One rider's leg of a plan, in the shape reconcile expects back. */
|
||||
export interface PlannedRider {
|
||||
rider_id: string | number;
|
||||
rider_name?: string;
|
||||
orders: SequencedStop[];
|
||||
}
|
||||
|
||||
export class OptimiserError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'OptimiserError';
|
||||
}
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${OPTIMISER_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
// A separate host means a separate failure mode: the optimiser can be down
|
||||
// while Fiesta is fine. Said plainly so nobody debugs the wrong service.
|
||||
throw new OptimiserError('Could not reach the route optimiser');
|
||||
}
|
||||
|
||||
let payload: { code?: number; details?: T; message?: string; error?: { message?: string } };
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new OptimiserError(`The optimiser sent a malformed reply (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new OptimiserError(payload?.error?.message || payload?.message || `Optimiser refused the request (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
// It answers `{code, details}` on the sequencing route and a bare object
|
||||
// elsewhere, so both shapes are unwrapped here rather than at each call site.
|
||||
return (payload.details ?? (payload as unknown)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* A run that is allowed to take its time, and to be cancelled.
|
||||
*
|
||||
* Separate from `post` for two reasons: the caller needs the whole envelope
|
||||
* rather than `details`, and a solve is slow enough that abandoning it has to
|
||||
* be possible. The caller's cancel and the timeout both have to be able to stop
|
||||
* it, so they are combined rather than one winning.
|
||||
*/
|
||||
async function postRaw(path: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
|
||||
const timer = new AbortController();
|
||||
const stop = setTimeout(() => timer.abort(), SOLVE_TIMEOUT_MS);
|
||||
const onAbort = () => timer.abort();
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${OPTIMISER_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: timer.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// 422 is the solver rejecting the payload and saying which field. Worth
|
||||
// showing verbatim — "422" on its own is not actionable.
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new OptimiserError(
|
||||
text.trim().slice(0, 400) || `Optimiser refused the request (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof OptimiserError) throw error;
|
||||
if ((error as Error)?.name === 'AbortError') {
|
||||
throw new OptimiserError(
|
||||
signal?.aborted
|
||||
? 'Cancelled.'
|
||||
: 'The optimiser did not answer in time. Nothing was assigned — the orders are untouched.',
|
||||
);
|
||||
}
|
||||
throw new OptimiserError('Could not reach the route optimiser');
|
||||
} finally {
|
||||
clearTimeout(stop);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export const optimiserApi = {
|
||||
/**
|
||||
* Put a set of orders in a sensible order.
|
||||
*
|
||||
* Send them in any order; they come back sorted with a step number and the
|
||||
* distance and time between each. Verified against the live service with our
|
||||
* own field names — it reads `pickuplat`/`pickuplong` and
|
||||
* `deliverylat`/`deliverylong`, which order rows already carry.
|
||||
*/
|
||||
sequence: (orders: OrderRow[]) =>
|
||||
post<SequencedStop[]>('/optimization/createdeliveries', orders),
|
||||
|
||||
/**
|
||||
* Repair step numbers after somebody moved a stop by hand.
|
||||
*
|
||||
* Moving one order between riders breaks two rounds at once: the rider who
|
||||
* lost it has a hole in its sequence (1,2,3,5,6) and the one who gained it
|
||||
* has a step that collides or is missing. This fixes both.
|
||||
*
|
||||
* It MUST run before the plan is committed. The team who built the page this
|
||||
* came from call skipping it "the single biggest production bug to avoid in
|
||||
* this area" — it corrupts route sequences in the database, and nothing at
|
||||
* the point of the write can tell.
|
||||
*
|
||||
* Send only the riders that were edited; the response carries those riders
|
||||
* back and the rest of the plan is left alone.
|
||||
*/
|
||||
reconcile: (riders: PlannedRider[]) =>
|
||||
post<{ riders: PlannedRider[] }>('/optimization/reconcile-steps', { riders }),
|
||||
|
||||
/**
|
||||
* Propose a rider for each waiting order.
|
||||
*
|
||||
* A PLAN, not a commitment. Nothing is written anywhere until the operator
|
||||
* accepts it and the console makes its own `createdeliveries` call to Fiesta
|
||||
* through `buildDelivery` — the same path the manual assign bar uses, so
|
||||
* there is exactly one way a delivery is ever written. Safe to run twice and
|
||||
* safe to walk away from.
|
||||
*
|
||||
* ── Raw, not unwrapped ────────────────────────────────────────────────────
|
||||
*
|
||||
* `postRaw`, because the answer here IS the envelope: `zones` carries the
|
||||
* assignment, `meta` carries the accounting and the per-order reasons, and
|
||||
* `details` is only the flat fallback shape. `post` would hand back `details`
|
||||
* alone and throw the plan away — and `details` is `[]` on every run that
|
||||
* assigns nothing, which is every run today.
|
||||
*
|
||||
* ── Slow on purpose ───────────────────────────────────────────────────────
|
||||
*
|
||||
* Seven seconds for five orders, measured, and it is a solver so it grows
|
||||
* with the problem. `signal` is taken so a caller can offer to cancel; the
|
||||
* timeout is deliberately generous, since killing a run early abandons work
|
||||
* the operator is waiting on and teaches them the button is broken.
|
||||
*
|
||||
* `tuning` steers it — balanced, aggressive_speed, fuel_saver, zone_strict —
|
||||
* and the literal string `null` is a value it accepts, meaning "your default".
|
||||
*/
|
||||
assign: (request: SolverRequest, tuning: Tuning | null, signal?: AbortSignal) =>
|
||||
postRaw(
|
||||
`/optimization/riderassign?hypertuning_params=${tuning ?? 'null'}`,
|
||||
request,
|
||||
signal,
|
||||
),
|
||||
};
|
||||
252
src/api/people.ts
Normal file
252
src/api/people.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* People — back-office staff and till accounts.
|
||||
*
|
||||
* Two account systems that happen to share one table. A till account is NOT a
|
||||
* Nearle Daily user: the backend excludes roles 7 and 8 from every application
|
||||
* lookup inside the query itself, deliberately, so a cashier is "not found"
|
||||
* rather than "refused". They are read and written through different endpoints
|
||||
* with different conventions, and this file keeps them apart.
|
||||
*
|
||||
* Three things this module will not do, each for a reason recorded in
|
||||
* `store-admin-user-menu-plan.md`:
|
||||
*
|
||||
* - **No delete.** `DELETE /users/delete` and `DELETE /deleteposuser` are hard
|
||||
* deletes with no cascade. Deactivating via `status` is the safe equivalent
|
||||
* and is what both list screens offer.
|
||||
* - **No password management.** `PUT /users/update` doubles as the
|
||||
* password-reset call and passwords are stored in clear. Creating an account
|
||||
* is in scope; issuing its password is not, until hashing exists.
|
||||
* - **Never send `roleid: -1`.** The old console clears a role that way;
|
||||
* `UpdateStaff` does not special-case it, so `-1` lands in the column and the
|
||||
* account ends up holding a role that matches nothing.
|
||||
*/
|
||||
|
||||
import { api, WEB } from './client';
|
||||
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
|
||||
|
||||
/* ── Back-office staff ───────────────────────────────────────────────────── */
|
||||
|
||||
export interface CreateStaffRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
firstname: string;
|
||||
lastname?: string;
|
||||
email: string;
|
||||
contactno: string;
|
||||
roleid: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UpdateStaffRequest {
|
||||
userid: number;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
email?: string;
|
||||
contactno?: string;
|
||||
roleid?: number;
|
||||
locationid?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const staffApi = {
|
||||
/**
|
||||
* The tenant's back-office directory.
|
||||
*
|
||||
* `getstaffs`, NOT `getallusers`. This one resolves `rolename` server-side,
|
||||
* and the backend says why that matters: "`app_roles` holds six rows for four
|
||||
* back-office roles and most accounts carry an id absent from it, so any
|
||||
* mapping written client-side is wrong."
|
||||
*
|
||||
* On WEB now. It used to be on MOB because `getstaffs` was registered under
|
||||
* `/v1/mob/tenants` alone and had no `/web` twin — back-office staff were
|
||||
* reachable only through the customer app's door, which is a large part of
|
||||
* why this console never had a people screen. The twin now exists; the MOB
|
||||
* registration is left in place in case something else calls it.
|
||||
*
|
||||
* Returns people with NO branch as well as people with one. That is the whole
|
||||
* point: `locationid` 0 means hired and not yet placed, and the list is
|
||||
* ordered to put them first, because they are the rows needing an action.
|
||||
*/
|
||||
list: (tenantid: number) =>
|
||||
api.list<StaffInfo>(`${WEB}/tenants/getstaffs`, { tenantid }),
|
||||
|
||||
/**
|
||||
* Put somebody at a branch, or take them off one.
|
||||
*
|
||||
* `unassign` is a separate flag rather than `locationid: 0`, deliberately. A
|
||||
* body that lost the field, a form that posted a blank and a client that
|
||||
* dropped it all arrive as 0 — so a zero alone must never mean "take them off
|
||||
* their shop". The backend refuses it too; this mirrors the rule so the
|
||||
* refusal is not a round trip.
|
||||
*/
|
||||
assign: (body: { tenantid: number; userid: number; locationid: number }) =>
|
||||
api.put<unknown>(`${WEB}/tenants/assignstaff`, body),
|
||||
|
||||
unassign: (body: { tenantid: number; userid: number }) =>
|
||||
api.put<unknown>(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
|
||||
|
||||
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
|
||||
|
||||
/**
|
||||
* Update a person.
|
||||
*
|
||||
* GORM's `Updates` with a struct skips zero values, so an omitted field is
|
||||
* left alone rather than blanked — which is why every field here is optional
|
||||
* and why clearing something is not possible through this call.
|
||||
*/
|
||||
update: (body: UpdateStaffRequest) => api.put<StaffInfo>(`${WEB}/users/update`, body),
|
||||
};
|
||||
|
||||
/* ── Till accounts ───────────────────────────────────────────────────────── */
|
||||
|
||||
export interface CreatePosUserRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
full_name: string;
|
||||
/**
|
||||
* The role NAME, lowercase — "supervisor" or "cashier".
|
||||
*
|
||||
* Not the id. `PosRoleFromName` reads the name off the request and returns 0
|
||||
* for anything it does not recognise, which every caller treats as a refusal
|
||||
* rather than as a default. Sending 7 or 8 here does nothing.
|
||||
*/
|
||||
role: string;
|
||||
/** Ten digits. The till matches on it exactly — see the note in `normaliseMobile`. */
|
||||
contactno: string;
|
||||
pin?: string;
|
||||
/** A `staffshifts.staff_shift_id`. Zero leaves it unset. */
|
||||
shift_id?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePosUserRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
user_id: number;
|
||||
full_name?: string;
|
||||
role?: string;
|
||||
contactno?: string;
|
||||
shift_id?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const posUsersApi = {
|
||||
/**
|
||||
* Till accounts at one outlet. `locationid` is required and singular.
|
||||
*
|
||||
* The envelope's `details` is an OBJECT — `{location_id, users}` — not the
|
||||
* array it reads like (`posController.go:858-860`). Asking for it as a list
|
||||
* returned an empty one every time, silently: the guard in `api.list` sees a
|
||||
* non-array and hands back `[]`, so the page showed "no till accounts" for a
|
||||
* shop that had them. Same shape trap as `/health/location`.
|
||||
*/
|
||||
list: (tenantid: number, locationid: number, includeInactive = false) =>
|
||||
api
|
||||
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
|
||||
tenantid,
|
||||
locationid,
|
||||
/*
|
||||
Off by default, because that is what every existing caller assumed.
|
||||
|
||||
The listing excludes inactive accounts unless asked
|
||||
(`posUserRepository.go:494` — `LOWER(COALESCE(a.status,'active')) <>
|
||||
'inactive'`), and `WebListPosUsers` reads `include_inactive` from the
|
||||
query string. Nothing sent it, which had two consequences: a supervisor
|
||||
who switched a cashier off in the drawer watched them disappear from the
|
||||
list with no trace and no way back, and any Status column could only
|
||||
ever render "Active" because that was the only status a row could have.
|
||||
*/
|
||||
...(includeInactive ? { include_inactive: 'true' } : {}),
|
||||
})
|
||||
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
|
||||
|
||||
/**
|
||||
* The role picker's source.
|
||||
*
|
||||
* Read rather than hardcoded. Supervisor is 7 and Cashier is 8 today, but the
|
||||
* endpoint also carries the label and the description a person needs to
|
||||
* choose between them — and a third role would appear here first.
|
||||
*/
|
||||
roles: () => api.list<PosRole>(`${WEB}/tenants/posroles`),
|
||||
|
||||
/**
|
||||
* Create a till account.
|
||||
*
|
||||
* The response carries the PIN or password ONCE. The backend is explicit that
|
||||
* a listing never returns it: "An admin who loses it reissues rather than
|
||||
* looks it up." So it is shown at creation and never read back.
|
||||
*/
|
||||
create: (body: CreatePosUserRequest) => api.post<PosUser>(`${WEB}/tenants/createposuser`, body),
|
||||
|
||||
update: (body: UpdatePosUserRequest) => api.put<PosUser>(`${WEB}/tenants/updateposuser`, body),
|
||||
|
||||
/**
|
||||
* Shift windows a till account can be put on.
|
||||
*
|
||||
* Wrapped the same way — `{location_id, shifts}` (`posController.go:934-936`).
|
||||
*/
|
||||
/**
|
||||
* The tenant's shift windows.
|
||||
*
|
||||
* `locationid` is optional and usually omitted. A shift belongs to the
|
||||
* business, so the tenant's set is what every picker in the console should
|
||||
* offer; naming a branch narrows to the tenant's plus that branch's own, for
|
||||
* the outlet that genuinely runs different hours.
|
||||
*/
|
||||
shifts: (tenantid: number, locationid?: number) =>
|
||||
api
|
||||
.get<{ location_id?: number; shifts?: StaffShift[] }>(`${WEB}/tenants/getstaffshifts`, {
|
||||
tenantid,
|
||||
...(locationid ? { locationid } : {}),
|
||||
})
|
||||
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
|
||||
|
||||
/**
|
||||
* Open a shift window at one branch.
|
||||
*
|
||||
* The endpoint has existed since till staff were built; nothing in the console
|
||||
* called it. So `getstaffshifts` answered `{"shifts": []}` at every branch —
|
||||
* the comment on StoreStaffPage says exactly that — and the picker on this
|
||||
* drawer offered "Any shift" and nothing else, for everyone, permanently.
|
||||
*
|
||||
* `weekdays` is a seven-character mask starting Monday; empty means every day.
|
||||
* The server rejects anything that is not seven 0/1 characters, so it is sent
|
||||
* as the mask rather than as a list the console would have to encode twice.
|
||||
*/
|
||||
createShift: (
|
||||
tenantid: number,
|
||||
shift: { name: string; start_time: string; end_time: string; weekdays?: string },
|
||||
/** Only for a shop that genuinely runs different hours from the business. */
|
||||
locationid?: number,
|
||||
) =>
|
||||
api.post<StaffShift>(`${WEB}/tenants/createstaffshift`, {
|
||||
tenantid,
|
||||
// Zero means the whole tenant, which is the ordinary case.
|
||||
locationid: locationid ?? 0,
|
||||
...shift,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Ten digits, or nothing.
|
||||
*
|
||||
* The till matches the mobile number EXACTLY, so `+91 98765 43210` typed back
|
||||
* as `9876543210` would not find the row. Stripping to the last ten digits at
|
||||
* the edge means an admin can paste whatever their contact list gave them.
|
||||
*/
|
||||
export function normaliseMobile(input: string): string {
|
||||
const digits = input.replace(/\D/g, '');
|
||||
return digits.length > 10 ? digits.slice(-10) : digits;
|
||||
}
|
||||
|
||||
/** Mon-first mask → "Mon–Fri", "Every day". `weekdays` empty means every day. */
|
||||
export function weekdayLabel(mask: string | undefined): string {
|
||||
if (!mask || !/^[01]{7}$/.test(mask)) return 'Every day';
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const on = [...mask].map((bit, index) => (bit === '1' ? days[index] : null)).filter(Boolean);
|
||||
if (on.length === 7) return 'Every day';
|
||||
if (on.length === 0) return '—';
|
||||
if (mask === '1111100') return 'Mon–Fri';
|
||||
if (mask === '0000011') return 'Weekends';
|
||||
return on.join(', ');
|
||||
}
|
||||
435
src/api/products.ts
Normal file
435
src/api/products.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Product, stock and import endpoints.
|
||||
*
|
||||
* Two import paths exist and they are NOT symmetric — the asymmetry is the
|
||||
* backend's, not a choice made here:
|
||||
*
|
||||
* Catalogue path : one batch call, idempotent. Re-importing the same
|
||||
* (tenantid, brand, catalogueid) tops up stock and
|
||||
* overwrites price instead of duplicating.
|
||||
*
|
||||
* Sheet path : `create` accepts ONE product, not an array, and does not
|
||||
* return the generated productid — the controller passes the
|
||||
* struct to the service by value, so GORM writes the id into
|
||||
* a copy that is then discarded, and the response echoes what
|
||||
* was sent. So a sheet import is N creates, then a lookup by
|
||||
* SKU to resolve ids, then one batched location call and one
|
||||
* batched stock call.
|
||||
*
|
||||
* `importSheetProducts` below encapsulates that whole dance so no screen has to
|
||||
* know about it.
|
||||
*/
|
||||
|
||||
import { api, MOB, WEB } from './client';
|
||||
import type {
|
||||
ImportCatalogueProductRequest,
|
||||
Product,
|
||||
ProductCategory,
|
||||
ProductLocationRequest,
|
||||
ProductStockRequest,
|
||||
ProductSubCategory,
|
||||
} from './types';
|
||||
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
|
||||
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
|
||||
|
||||
export interface LocationProductQuery {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
|
||||
export const productsApi = {
|
||||
/**
|
||||
* A store's own catalogue — what is actually imported, with live stock.
|
||||
*
|
||||
* `pageno` is 1-BASED on the backend: `GetLocationProducts` clamps anything
|
||||
* below 1 up to 1 (`productRepository.go:453`). So page 0 and page 1 both
|
||||
* return the first page, and a caller counting from zero fetches page one
|
||||
* twice and never sees the last one. The `+ 1` here is what makes a 0-based
|
||||
* caller correct rather than off by one.
|
||||
*
|
||||
* `pagesize` defaults to 200 rather than 50 because nothing in the console
|
||||
* paginates this yet: both call sites ask for one page and render it, so a
|
||||
* shop with 80 products was showing 50 and silently dropping the rest.
|
||||
*/
|
||||
locationProducts: (query: LocationProductQuery) =>
|
||||
api.list<Product>(`${WEB}/products/getlocationproducts`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
pageno: (query.pageno ?? 0) + 1,
|
||||
pagesize: query.pagesize ?? 200,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Every product a tenant owns, catalogue-imported or created.
|
||||
*
|
||||
* The payload is NOT a product list. It is `[]models.Tenantproducts` —
|
||||
* `{tenant, products}` groups, one per tenant (`models/product.go:246`) — and
|
||||
* it arrives under `data`, not `details`. Asked for as a flat list it handed
|
||||
* back one wrapper object whose keys are `tenant` and `products`, which the
|
||||
* 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
|
||||
.list<{ products?: Product[] }>(`${WEB}/products/getallproducts`, { tenantid })
|
||||
.then((groups) => groups.flatMap((group) => group?.products ?? [])),
|
||||
|
||||
count: (tenantid: number) =>
|
||||
api.get<{ count?: number }>(`${WEB}/products/getproductscount`, { tenantid }),
|
||||
|
||||
categories: (tenantid: number) =>
|
||||
api.list<ProductCategory>(`${WEB}/products/getproductcategories`, { tenantid }),
|
||||
|
||||
subCategories: (tenantid: number, categoryid: number) =>
|
||||
api.list<ProductSubCategory>(`${WEB}/products/getproductsubcategories`, {
|
||||
tenantid,
|
||||
categoryid,
|
||||
}),
|
||||
|
||||
/** Batch, idempotent. Send the whole selection in one call. */
|
||||
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
|
||||
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
|
||||
|
||||
/**
|
||||
* 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),
|
||||
|
||||
/** Array. Upserts on (tenantid, locationid, productid). */
|
||||
createProductLocations: (rows: ProductLocationRequest[]) =>
|
||||
api.post<unknown>(`${WEB}/products/createproductlocation`, rows),
|
||||
|
||||
/** Array. Appends to the stock ledger. */
|
||||
createProductStock: (rows: ProductStockRequest[]) =>
|
||||
api.post<unknown>(`${WEB}/products/createproductstock`, rows),
|
||||
|
||||
/**
|
||||
* Price a product and release it to the shops.
|
||||
*
|
||||
* There is NO `locationid` — deliberately, on the backend's side. It reads
|
||||
* the tenant's active outlets itself, because "a console that sent its own
|
||||
* list could publish to a subset by omission"
|
||||
* (`productPublishRepository.go:50`). One call sets this price at every
|
||||
* branch and also writes `products.retailprice` and `taxpercent`.
|
||||
*
|
||||
* Refuses `price <= 0`.
|
||||
*/
|
||||
publish: (body: {
|
||||
tenantid: number;
|
||||
productid: number;
|
||||
price: number;
|
||||
taxpercent: number;
|
||||
}) => api.post<unknown>(`${WEB}/products/publishproduct`, body),
|
||||
|
||||
/**
|
||||
* Clear `publishedat`. Narrower than it sounds — the till and the customer
|
||||
* app do not filter on this column, so this hides the product from the store
|
||||
* catalogue view and nothing else. See `Product.publishedat`.
|
||||
*/
|
||||
unpublish: (body: { tenantid: number; productid: number }) =>
|
||||
api.post<unknown>(`${WEB}/products/unpublishproduct`, body),
|
||||
|
||||
/**
|
||||
* The tenant's real category list, synthesised from products in use.
|
||||
*
|
||||
* Not `getproductcategories` — that reads a master table missing rows for
|
||||
* categoryids live in production, hardcoded to `moduleid = 2`, unscoped.
|
||||
*/
|
||||
tenantCategories: (tenantid: number) =>
|
||||
api.list<{ categoryid: number; categoryname: string }>(
|
||||
`${WEB}/products/gettenantcategories`,
|
||||
{ tenantid },
|
||||
),
|
||||
|
||||
/**
|
||||
* Exchanges category NAMES for this tenant's category ids, creating any that
|
||||
* do not exist yet.
|
||||
*
|
||||
* POST because it writes: a sheet naming an aisle this shop has never stocked
|
||||
* opens the aisle rather than failing. Keyed on the lowercased, trimmed name,
|
||||
* so a caller looks up whatever casing its own sheet used.
|
||||
*/
|
||||
resolveCategories: (tenantid: number, names: string[]) =>
|
||||
api.post<Record<string, number>>(`${WEB}/products/resolvecategories`, { tenantid, names }),
|
||||
|
||||
/**
|
||||
* Re-files products into different aisles, in bulk.
|
||||
*
|
||||
* `categoryid` should be 2 on every row — the customer app FILTERS on it and
|
||||
* anything else removes the product from its browse. `subcategoryid` is the
|
||||
* one that decides the heading a shopper reads; 0 leaves whatever the product
|
||||
* already has, so a caller that does not know the aisle cannot erase one.
|
||||
*
|
||||
* Scoped by tenant on the server as well as here — a productid is global, so
|
||||
* a wrong id in the list would otherwise move another merchant's product.
|
||||
*/
|
||||
recategorise: (
|
||||
tenantid: number,
|
||||
updates: { productid: number; categoryid: number; subcategoryid?: number }[],
|
||||
) => api.put<{ moved: number }>(`${WEB}/products/recategorise`, { tenantid, updates }),
|
||||
|
||||
/** Unlinks from the store. The product row and its order history survive. */
|
||||
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
|
||||
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
|
||||
};
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
The sheet-import dance
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** One validated row from the uploaded workbook. */
|
||||
export interface SheetProductRow {
|
||||
productname: string;
|
||||
productsku: string;
|
||||
/**
|
||||
* The category NAME, from the ladder in `productCategory.ts`.
|
||||
*
|
||||
* The name is what the pipeline and the catalogue both speak; the id is
|
||||
* per-tenant and is resolved from this at import time.
|
||||
*/
|
||||
category?: string;
|
||||
categoryid: number;
|
||||
subcategoryid: number;
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
taxpercent: number;
|
||||
quantity: number;
|
||||
productunit?: string;
|
||||
unitvalue?: string;
|
||||
productbrand?: string;
|
||||
productdesc?: string;
|
||||
}
|
||||
|
||||
export interface SheetImportResult {
|
||||
created: number;
|
||||
linked: number;
|
||||
stocked: number;
|
||||
/** Rows the backend rejected, paired with the reason, so they can be retried. */
|
||||
failures: { row: SheetProductRow; reason: string }[];
|
||||
}
|
||||
|
||||
export interface SheetImportOptions {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
rows: SheetProductRow[];
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a parsed sheet.
|
||||
*
|
||||
* NOT idempotent, and it cannot be made so from this side: nothing in the API
|
||||
* dedupes on SKU, so uploading the same workbook twice creates the products
|
||||
* twice. The importer UI is responsible for warning before a re-upload.
|
||||
*
|
||||
* Creates run sequentially rather than in parallel on purpose. There is no
|
||||
* batch create, and firing 500 concurrent writes at a single-instance Go
|
||||
* service to save a few seconds is a poor trade against a half-imported tenant.
|
||||
*/
|
||||
/**
|
||||
* NO LONGER WIRED TO ANY SCREEN.
|
||||
*
|
||||
* The Upload sheet panel now hands the workbook to the ingest service
|
||||
* (`api/ingest.ts`) instead of running this loop from the browser. Kept, not
|
||||
* deleted, because the ingest contract is still unconfirmed and this is the
|
||||
* known-working path back if that service turns out not to fit. Delete it once
|
||||
* the ingest has run against real data and been signed off — a second import
|
||||
* path that nobody calls is a thing that rots.
|
||||
*/
|
||||
export async function importSheetProducts(
|
||||
options: SheetImportOptions,
|
||||
): Promise<SheetImportResult> {
|
||||
const { tenantid, locationid, rows, onProgress } = options;
|
||||
const failures: SheetImportResult['failures'] = [];
|
||||
const createdSkus: string[] = [];
|
||||
|
||||
/*
|
||||
The aisles first, in one call, before a single product is created.
|
||||
|
||||
Every row carries a category NAME worked out by the ladder in
|
||||
`productCategory.ts`. What the customer app groups by is not that category
|
||||
but `products.subcategoryid` — one of ten platform rows under category 2 —
|
||||
so the name is folded into an aisle and the aisle looked up by name here. See
|
||||
`appAisle.ts` for the endpoint that is measured against.
|
||||
|
||||
A failure here is not fatal: the lookup falls back to the ids last read from
|
||||
the platform, and a row that still cannot be placed is created with
|
||||
subcategoryid 0, which the app lists under "Uncategorized".
|
||||
*/
|
||||
let aisleIds: ReadonlyMap<string, number>;
|
||||
try {
|
||||
aisleIds = aisleIdsFrom(await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY));
|
||||
} catch {
|
||||
aisleIds = aisleIdsFrom(undefined);
|
||||
}
|
||||
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 {
|
||||
const created = await productsApi.createProduct({
|
||||
tenantid,
|
||||
productname: row.productname,
|
||||
productsku: row.productsku,
|
||||
// ALWAYS 2 — the app filters on it and would drop anything else. The
|
||||
// aisle a shopper reads is the subcategory.
|
||||
categoryid: APP_BROWSE_CATEGORY,
|
||||
subcategoryid: subcategoryIdFor(row),
|
||||
retailprice: row.retailprice,
|
||||
productcost: row.productcost,
|
||||
taxpercent: row.taxpercent,
|
||||
productunit: row.productunit,
|
||||
unitvalue: row.unitvalue,
|
||||
productbrand: row.productbrand,
|
||||
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 (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
|
||||
if (stockRows.length > 0) await productsApi.createProductStock(stockRows);
|
||||
|
||||
return {
|
||||
created: createdSkus.length,
|
||||
linked: locationRows.length,
|
||||
stocked: stockRows.length,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Variants: one product, several sizes ────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A size under a parent product.
|
||||
*
|
||||
* `variantproductid` is a REAL product row — its own price, its own stock, its
|
||||
* own barcode — which is why a variant carries none of them. That is the whole
|
||||
* design: "Cadbury 5 Star 18g" and "9.8g" stay two products the shop counts
|
||||
* separately, and the app shows one card with a size picker.
|
||||
*
|
||||
* `variantname` is what the picker shows. It is free text rather than derived
|
||||
* from the product name, because "Aachi Baby Fryums 500g" should read as "500g"
|
||||
* in a row of three buttons, not repeat the brand three times.
|
||||
*/
|
||||
export interface ProductVariantLink {
|
||||
variantid?: number;
|
||||
tenantid: number;
|
||||
/** The parent — the product the app shows. */
|
||||
productid: number;
|
||||
/** The product actually added to the basket for this size. */
|
||||
variantproductid: number;
|
||||
variantname: string;
|
||||
varianttype?: string;
|
||||
}
|
||||
|
||||
export const variantsApi = {
|
||||
/**
|
||||
* Group a product under a parent.
|
||||
*
|
||||
* The backend refuses a self-reference, a parent or child belonging to
|
||||
* another tenant, and a duplicate link — so the console does not need to
|
||||
* re-check any of that, only to show the reason.
|
||||
*/
|
||||
add: (link: ProductVariantLink) =>
|
||||
api.post<ProductVariantLink>(`${WEB}/products/addproductvariant`, link),
|
||||
|
||||
/**
|
||||
* Ungroup. The product itself is untouched — only the link goes.
|
||||
*
|
||||
* Keyed on `variantid`, the link's own id, not on the two product ids. The
|
||||
* backend refuses anything else with "tenantid and variantid are both
|
||||
* required", so the caller has to have read the link before it can drop it.
|
||||
*/
|
||||
remove: (params: { tenantid: number; variantid: number }) =>
|
||||
api.del<unknown>(`${WEB}/products/removeproductvariant`, undefined, params),
|
||||
};
|
||||
|
||||
/**
|
||||
* The sizes under one product, as the customer app receives them.
|
||||
*
|
||||
* `/v1/mob`, not `/v1/web` — this endpoint exists only on the mobile group, and
|
||||
* calling the web path 404s. Reading it from the console is deliberate: it is
|
||||
* the only way to show a merchant exactly what a shopper will see, rather than
|
||||
* a second rendering of the same links that can drift from it.
|
||||
*
|
||||
* The first entry is the PARENT ITSELF. A parent is one of its own sizes, so a
|
||||
* picker of three has three entries, not a parent plus two.
|
||||
*/
|
||||
export const variantPreviewApi = {
|
||||
forProduct: (params: { productid: number; tenantid: number; locationid: number }) =>
|
||||
api.list<Product>(`${MOB}/products/getproductbyvariant`, params),
|
||||
};
|
||||
135
src/api/routing.ts
Normal file
135
src/api/routing.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Real road geometry between two points.
|
||||
*
|
||||
* ── Why a third service ─────────────────────────────────────────────────────
|
||||
*
|
||||
* A straight line between two drops is not a route. On a map it cuts through
|
||||
* blocks and across rivers, and its length is not the distance anybody rode —
|
||||
* so a line drawn that way invites a measurement it cannot support. OSRM
|
||||
* returns the actual road path, which is both honest and immediately readable
|
||||
* as "they went round the one-way system".
|
||||
*
|
||||
* `router.project-osrm.org` is the project's own demo server. Verified reachable
|
||||
* 2026-09-10 (200 in ~1.1 s for a Coimbatore leg). It is a courtesy service with
|
||||
* no SLA and a fair-use policy, which shapes everything below: legs are cached,
|
||||
* requests are capped per draw, and a failure is silent because a map that
|
||||
* loses its road geometry is still a useful map.
|
||||
*
|
||||
* ── Failure is expected and must be cheap ───────────────────────────────────
|
||||
*
|
||||
* Every caller takes back a path or null, never an error. A null means "draw the
|
||||
* straight line instead", which is what the map already did. Nothing about a
|
||||
* dispatch board should break because a free routing server was busy.
|
||||
*/
|
||||
|
||||
const OSRM_BASE = (
|
||||
import.meta.env?.['VITE_OSRM_BASE'] ?? 'https://router.project-osrm.org'
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
/** One leg's road geometry, as [lat, lng] pairs ready for a polyline. */
|
||||
export type RoadPath = { lat: number; lng: number }[];
|
||||
|
||||
export interface Leg {
|
||||
from: { lat: number; lng: number };
|
||||
to: { lat: number; lng: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legs already fetched, keyed on their rounded endpoints.
|
||||
*
|
||||
* Module-level and unbounded on purpose within a session: a dispatch board
|
||||
* redraws constantly — every filter, every poll — and the same legs recur. Two
|
||||
* hundred legs of geometry is a few hundred kilobytes, and re-fetching them
|
||||
* from a courtesy server on each render is the behaviour that gets an IP
|
||||
* blocked.
|
||||
*/
|
||||
const cache = new Map<string, RoadPath | null>();
|
||||
|
||||
/** Five decimal places is about a metre — finer than any two drops differ by. */
|
||||
function keyOf(leg: Leg): string {
|
||||
return `${leg.from.lat.toFixed(5)},${leg.from.lng.toFixed(5)};${leg.to.lat.toFixed(5)},${leg.to.lng.toFixed(5)}`;
|
||||
}
|
||||
|
||||
/** A single leg's road path, or null when it cannot be had. */
|
||||
async function fetchLeg(leg: Leg, signal?: AbortSignal): Promise<RoadPath | null> {
|
||||
const url =
|
||||
`${OSRM_BASE}/route/v1/driving/` +
|
||||
`${leg.from.lng},${leg.from.lat};${leg.to.lng},${leg.to.lat}` +
|
||||
`?overview=full&geometries=geojson`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) return null;
|
||||
const body = (await response.json()) as {
|
||||
routes?: { geometry?: { coordinates?: [number, number][] } }[];
|
||||
};
|
||||
const coordinates = body.routes?.[0]?.geometry?.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
|
||||
// GeoJSON is [lng, lat]; leaflet wants lat first. Getting this backwards
|
||||
// puts Coimbatore in the Arabian Sea, which is the classic symptom.
|
||||
return coordinates.map(([lng, lat]) => ({ lat, lng }));
|
||||
} catch {
|
||||
// Includes the abort. A cancelled draw wants no path, same as a failed one.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many legs one draw may ask for.
|
||||
*
|
||||
* A hundred-drop round is ninety-nine legs, and asking a courtesy server for
|
||||
* all of them at once is how a shared IP gets rate-limited for everybody. Past
|
||||
* this the map falls back to straight lines, which it can always draw.
|
||||
*/
|
||||
const MAX_LEGS_PER_DRAW = 60;
|
||||
|
||||
/** How many requests are in flight at once. Polite, and enough to feel instant. */
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
export const routingApi = {
|
||||
/**
|
||||
* Road geometry for a set of legs.
|
||||
*
|
||||
* Returns a map keyed the same way the caller can look up — `keyFor(leg)` —
|
||||
* holding a path or null per leg. Cached legs cost nothing; uncached ones are
|
||||
* fetched a few at a time.
|
||||
*
|
||||
* Never throws. A leg that could not be routed is absent from the result and
|
||||
* the caller draws its straight line, which is what it did before.
|
||||
*/
|
||||
roads: async (legs: readonly Leg[], signal?: AbortSignal): Promise<Map<string, RoadPath>> => {
|
||||
const out = new Map<string, RoadPath>();
|
||||
const wanted: Leg[] = [];
|
||||
|
||||
for (const leg of legs) {
|
||||
const key = keyOf(leg);
|
||||
if (cache.has(key)) {
|
||||
const hit = cache.get(key);
|
||||
if (hit) out.set(key, hit);
|
||||
} else if (wanted.length < MAX_LEGS_PER_DRAW) {
|
||||
wanted.push(leg);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < wanted.length; i += CONCURRENCY) {
|
||||
if (signal?.aborted) break;
|
||||
const batch = wanted.slice(i, i + CONCURRENCY);
|
||||
const paths = await Promise.all(batch.map((leg) => fetchLeg(leg, signal)));
|
||||
batch.forEach((leg, index) => {
|
||||
const key = keyOf(leg);
|
||||
const path = paths[index] ?? null;
|
||||
// Null is cached too: a leg the router cannot do will not start working
|
||||
// if it is asked sixty more times this session.
|
||||
cache.set(key, path);
|
||||
if (path) out.set(key, path);
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
/** The key a leg's path is stored under, for callers reading the result. */
|
||||
keyFor: keyOf,
|
||||
};
|
||||
193
src/api/stock.ts
Normal file
193
src/api/stock.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Stock requests and stock movement — the Store Admin's approval surface.
|
||||
*
|
||||
* Read `store-admin-backend-gap.md` §2.2 before extending this file. The
|
||||
* approval workflow the spec describes does not exist in Fiesta: the request
|
||||
* table has no reason, requester, approved quantity, approver or remarks, and
|
||||
* `UpdateStockRequest(requestID, status)` takes a bare string. Exactly one
|
||||
* value does anything — "Received" — and it posts a stock movement for the FULL
|
||||
* requested quantity, guarded against double-receiving.
|
||||
*
|
||||
* So "approve for a different quantity" is not implemented here because it
|
||||
* cannot be implemented here. It is a backend change, not a frontend one.
|
||||
*/
|
||||
|
||||
import { api, WEB } from './client';
|
||||
import type { StockRequest, StockStatementRow } from './types';
|
||||
|
||||
/**
|
||||
* The status values the backend actually distinguishes.
|
||||
*
|
||||
* `Received` is the only one with behaviour. The others are stored verbatim and
|
||||
* read back, which is enough to drive a queue but is not a state machine — the
|
||||
* backend will accept any string at all.
|
||||
*/
|
||||
/**
|
||||
* The ladder a request climbs.
|
||||
*
|
||||
* `Approved` sits between asking and arriving, and adding it is the point:
|
||||
* approving used to put the stock on the shelf immediately, so the count said
|
||||
* the goods were there from the moment the admin agreed to send them — which is
|
||||
* days before they arrive, and the branch sells against a shelf that is empty.
|
||||
*
|
||||
* Only `Received` moves the ledger. Fiesta keys the stock write on that exact
|
||||
* word, so `Approved` is a status and nothing else.
|
||||
*/
|
||||
export const STOCK_REQUEST_STATUS = {
|
||||
pending: 'Pending',
|
||||
approved: 'Approved',
|
||||
received: 'Received',
|
||||
rejected: 'Rejected',
|
||||
} as const;
|
||||
|
||||
export type StockRequestStatus = (typeof STOCK_REQUEST_STATUS)[keyof typeof STOCK_REQUEST_STATUS];
|
||||
|
||||
export interface StockRequestQuery {
|
||||
tenantid: number;
|
||||
/** Omit for every branch. */
|
||||
locationid?: number;
|
||||
status?: string;
|
||||
date?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a batch actually did.
|
||||
*
|
||||
* Both lists are always read: a batch that half-worked is the case worth
|
||||
* reporting, and the failures name the row so somebody can go and look.
|
||||
*/
|
||||
export interface StockBatchOutcome {
|
||||
updated?: number[];
|
||||
created?: unknown[];
|
||||
failed?: { requestid?: number; productid?: number; reason: string }[];
|
||||
}
|
||||
|
||||
export interface CreateStockRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
productid: number;
|
||||
qty: number;
|
||||
/** Carried so the admin's queue can name the branch without a second read. */
|
||||
locationname?: string;
|
||||
productname?: string;
|
||||
}
|
||||
|
||||
export const stockApi = {
|
||||
/**
|
||||
* A branch asks its admin for stock.
|
||||
*
|
||||
* The only write a Store user has against inventory, and deliberately so:
|
||||
* nothing here moves the ledger. `status` is always Pending — the backend
|
||||
* defaults to it when blank, but sending it makes the intent explicit rather
|
||||
* than relying on a default that a later release could change.
|
||||
*
|
||||
* There is no reason field, no requester and no wanted-by date in
|
||||
* `stockrequests`, so the request carries a product and a quantity and
|
||||
* nothing else. Do not invent the rest in the UI.
|
||||
*/
|
||||
create: (body: CreateStockRequest) =>
|
||||
api.post<StockRequest>(`${WEB}/products/createstockrequest`, {
|
||||
...body,
|
||||
status: STOCK_REQUEST_STATUS.pending,
|
||||
}),
|
||||
|
||||
requests: (query: StockRequestQuery) =>
|
||||
api.list<StockRequest>(`${WEB}/products/getstockrequests`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
date: query.date,
|
||||
pageno: query.pageno ?? 0,
|
||||
pagesize: query.pagesize ?? 50,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Agree to send the stock. Nothing reaches the shelf yet.
|
||||
*
|
||||
* The shelf is written when the branch confirms the goods ARRIVED, not when
|
||||
* the admin agrees to send them — see `confirmArrival`.
|
||||
*/
|
||||
approve: (requestid: number) =>
|
||||
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
||||
requestid,
|
||||
status: STOCK_REQUEST_STATUS.approved,
|
||||
}),
|
||||
|
||||
/**
|
||||
* The goods turned up. THIS is what adds `request.qty` to the branch's stock.
|
||||
*
|
||||
* There is no way to receive a different amount: the service reads the
|
||||
* quantity off the request row, not off this call. A short delivery has to be
|
||||
* corrected on the stock ledger afterwards.
|
||||
*/
|
||||
confirmArrival: (requestid: number) =>
|
||||
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
||||
requestid,
|
||||
status: STOCK_REQUEST_STATUS.received,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Several requests at once.
|
||||
*
|
||||
* One call rather than a loop of them, because approving MOVES STOCK: a loop
|
||||
* that dies halfway leaves some deliveries received and some not, with nothing
|
||||
* to say which. The backend applies each id separately and reports both lists,
|
||||
* so a partial outcome is a fact the screen can show rather than a guess.
|
||||
*
|
||||
* The same status for the whole batch, never a mix. "Approve these" and
|
||||
* "reject these" are two decisions, and one call that could do both is how a
|
||||
* mis-click approves what it meant to refuse.
|
||||
*/
|
||||
decideMany: (requestids: number[], status: StockRequestStatus) =>
|
||||
api.put<StockBatchOutcome>(`${WEB}/products/updatestockrequest`, { requestids, status }),
|
||||
|
||||
approveMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.approved),
|
||||
confirmArrivalMany: (requestids: number[]) =>
|
||||
stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.received),
|
||||
rejectMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.rejected),
|
||||
|
||||
/**
|
||||
* A branch asks for several products in one go.
|
||||
*
|
||||
* Restocking after a delivery is one errand, not twenty. Sending it as twenty
|
||||
* calls is slow, and a dropped connection leaves a half-made request list that
|
||||
* nobody can tell apart from a deliberate one.
|
||||
*/
|
||||
createMany: (rows: CreateStockRequest[]) =>
|
||||
api.post<StockBatchOutcome>(`${WEB}/products/createstockrequest`,
|
||||
rows.map((row) => ({ ...row, status: STOCK_REQUEST_STATUS.pending }))),
|
||||
|
||||
/** Reject — a status write and nothing else. No stock moves, no reason stored. */
|
||||
reject: (requestid: number) =>
|
||||
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
||||
requestid,
|
||||
status: STOCK_REQUEST_STATUS.rejected,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Stock movement for one branch.
|
||||
*
|
||||
* Opening, credit, debit, closing — and that is the whole vocabulary.
|
||||
* `productstocks.stocktype` is `in`/`out`, so a sale, a transfer and a
|
||||
* correction are indistinguishable once written. The spec's seven movement
|
||||
* types (§3.8) cannot be sourced; see gap §2.5.
|
||||
*/
|
||||
statement: (params: {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
subcategoryid?: number;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}) =>
|
||||
api.list<StockStatementRow>(`${WEB}/products/getstockstatement`, {
|
||||
tenantid: params.tenantid,
|
||||
locationid: params.locationid,
|
||||
subcategoryid: params.subcategoryid,
|
||||
keyword: params.keyword,
|
||||
pageno: params.pageno ?? 0,
|
||||
pagesize: params.pagesize ?? 100,
|
||||
}),
|
||||
};
|
||||
112
src/api/telemetry.ts
Normal file
112
src/api/telemetry.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Where a rider is right now, and how their phone is doing.
|
||||
*
|
||||
* ── This is a different backend, and that is the whole point ────────────────
|
||||
*
|
||||
* `jupiter.nearle.app` — the platform's older API, which the xpress console
|
||||
* runs against. Fiesta has no equivalent: `getriderperiodiclogs` does not exist
|
||||
* there under any prefix, and `partners/getriderlogs`, the closest-looking
|
||||
* Fiesta endpoint, is a heartbeat log that repeats one fixed coordinate per
|
||||
* rider all day (see `riderShifts`). So this is the ONLY live rider position
|
||||
* on the platform, and it was missed once already by checking Fiesta alone.
|
||||
*
|
||||
* Verified 2026-09-10: rider 852 polled thirty seconds apart moved about 140 m,
|
||||
* with battery, connection and accuracy all changing. Genuinely live.
|
||||
*
|
||||
* Same database as Fiesta underneath — `getallriders` on jupiter and
|
||||
* `getriders` on Fiesta return identical rosters and identical on-duty state —
|
||||
* so a userid from one is a userid in the other.
|
||||
*
|
||||
* ── One rider per call ──────────────────────────────────────────────────────
|
||||
*
|
||||
* There is no fleet form: `?userid=N` answers for that rider, and omitting it
|
||||
* answers for whichever rider reported most recently, which is not useful. A
|
||||
* fleet view therefore fans out, which is fine at the sizes involved — a
|
||||
* merchant's round is a handful of riders.
|
||||
*
|
||||
* ── Freshness is the caller's problem, and must be shown ────────────────────
|
||||
*
|
||||
* The endpoint always answers, and it answers with the LAST known fix however
|
||||
* old. Riders 883, 897 and 1111 come back with positions from 5, 3 and 12 days
|
||||
* ago and nothing in the payload flags them as stale. A map that draws those
|
||||
* next to a live one is lying, so `logdate` is parsed here and every consumer
|
||||
* is handed an age rather than a bare position.
|
||||
*/
|
||||
|
||||
const JUPITER_BASE = (
|
||||
import.meta.env?.['VITE_JUPITER_BASE'] ?? 'https://jupiter.nearle.app'
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
/** One rider's live snapshot, exactly as jupiter sends it. */
|
||||
export interface RiderSnapshot {
|
||||
userid: number;
|
||||
username?: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
/** `2026-09-10 11:25:11` — server local time, no zone. */
|
||||
logdate?: string;
|
||||
/** `"95%"`, with the sign. */
|
||||
battery?: string;
|
||||
/** `mobile`, `wifi`, `none`. */
|
||||
connection?: string;
|
||||
/** Metres of GPS uncertainty, as a string. 100.0 is a poor fix. */
|
||||
accuracy?: string;
|
||||
/** Km/h and degrees, both as strings. */
|
||||
speed?: string;
|
||||
heading?: string;
|
||||
/** `idle`, `active`, and whatever else the app decides to send. */
|
||||
status?: string;
|
||||
/** The order they are on, when they are on one. */
|
||||
orderid?: string;
|
||||
is_charging?: boolean;
|
||||
is_background?: boolean;
|
||||
/** `enabled` / `disabled` — a disabled one explains a stale position. */
|
||||
location_service?: string;
|
||||
}
|
||||
|
||||
export class TelemetryError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TelemetryError';
|
||||
}
|
||||
}
|
||||
|
||||
export const telemetryApi = {
|
||||
/**
|
||||
* One rider's latest reported position and phone state.
|
||||
*
|
||||
* Never throws for "this rider has never reported" — that answers 200 with an
|
||||
* empty-ish body, and the caller wants to draw the rider as unreachable
|
||||
* rather than show an error. It throws only when jupiter itself cannot be
|
||||
* reached, which is a different thing and worth saying out loud: jupiter can
|
||||
* be down while Fiesta is fine, and vice versa.
|
||||
*/
|
||||
rider: async (userid: number, signal?: AbortSignal): Promise<RiderSnapshot | null> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
`${JUPITER_BASE}/live/api/v1/utils/getriderperiodiclogs?userid=${userid}`,
|
||||
{ headers: { Accept: 'application/json' }, signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') throw error;
|
||||
throw new TelemetryError(
|
||||
'Could not reach the live rider service. It is a separate backend from the rest of the console, so everything else keeps working.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TelemetryError(`The live rider service answered ${response.status}.`);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { data?: RiderSnapshot }
|
||||
| null;
|
||||
const data = payload?.data;
|
||||
// A rider who has never opened the app comes back without coordinates.
|
||||
// Null rather than an empty object, so "no fix" is one check everywhere.
|
||||
return data && (data.latitude || data.longitude) ? data : null;
|
||||
},
|
||||
};
|
||||
319
src/api/tenants.ts
Normal file
319
src/api/tenants.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
|
||||
|
||||
import { api, WEB } from './client';
|
||||
import type { AppLocation } from './deliveries';
|
||||
import type { TenantInfo, TenantLocation } from './types';
|
||||
|
||||
/** Everything the tenant-onboarding form collects. */
|
||||
export interface CreateTenantRequest {
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
primarycontact: string;
|
||||
primaryemail: string;
|
||||
/**
|
||||
* Who runs the shop — `tenants.firstname`.
|
||||
*
|
||||
* Asked here because here is the only moment it can be asked. The primary
|
||||
* branch and the merchant's own admin login are both created inside
|
||||
* `CreateTenantUser`'s transaction, and the login is copied from the tenant
|
||||
* row — so a name given now names the business AND the account that will
|
||||
* sign in. Left out, both are blank, which is what every merchant on the
|
||||
* platform currently has: the store profile prints "—" for Store admin and
|
||||
* the account carries no person's name at all.
|
||||
*
|
||||
* One field, not two: `tenants` has a `firstname` column and no
|
||||
* `lastname`, so this is the whole name.
|
||||
*/
|
||||
firstname?: string;
|
||||
locationname: string;
|
||||
categoryid: number;
|
||||
subcategoryid?: number;
|
||||
address: string;
|
||||
suburb?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
moduleid?: number;
|
||||
/** The city this merchant trades in. `app_location`, not a branch. */
|
||||
applocationid?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** Everything the branch-onboarding form collects. */
|
||||
export interface CreateBranchRequest {
|
||||
tenantid: number;
|
||||
/**
|
||||
* The delivery region, inherited from the tenant's existing outlets.
|
||||
*
|
||||
* `tenantlocations.applocationid` has no column default, so a create that
|
||||
* omits it stores 0 — and `resolveOfflineLocationContext` in
|
||||
* `orderRepository.go` calls this column "authoritative", with no fallback
|
||||
* anywhere for a zero. It is also copied straight onto the login the backend
|
||||
* spawns for the branch, so the outlet AND the person running it both end up
|
||||
* in no region at all.
|
||||
*
|
||||
* Measured 2026-09-15: 43 of 75 live branches carry 0. Regions are
|
||||
* 1 = Coimbatore, 2 = Madurai, 23 = Nagercoil.
|
||||
*/
|
||||
applocationid?: number;
|
||||
/**
|
||||
* Also inherited, and also without a column default.
|
||||
*
|
||||
* `orderRepository.go` documents the consequence in its own comment —
|
||||
* "tenantlocations carries 0 for moduleid/partnerid at outlets whose live
|
||||
* orders nonetheless use non-zero values" — and works around it by copying
|
||||
* the scaffolding off the most recent real order at that outlet. A branch
|
||||
* commissioned five minutes ago has no such order, so the workaround has
|
||||
* nothing to copy and the joins are left to resolve against a zero.
|
||||
*/
|
||||
moduleid?: number;
|
||||
locationname: string;
|
||||
email?: string;
|
||||
contactno?: string;
|
||||
address: string;
|
||||
suburb?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
opentime?: string;
|
||||
closetime?: string;
|
||||
deliveryradius?: number;
|
||||
deliverymins?: number;
|
||||
status?: string;
|
||||
/**
|
||||
* Who will run this outlet — an existing person, when one has been hired
|
||||
* already.
|
||||
*
|
||||
* Omitted, the backend spawns a login named after the SHOP, on the shop's
|
||||
* email address, one per outlet. That was the only option, and it is why two
|
||||
* people at a counter shared a credential and nothing recorded which of them
|
||||
* did anything.
|
||||
*
|
||||
* A branch must still arrive with SOMEBODY: name a person here, or give an
|
||||
* `email` to spawn one from. The backend refuses a branch with neither,
|
||||
* because an outlet nobody can sign in to is a dead end that shows up in
|
||||
* every list and is noticed by whoever is standing in the shop.
|
||||
*/
|
||||
operatorid?: number;
|
||||
}
|
||||
|
||||
export interface TenantListQuery {
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
/** `Active` / `InActive`. Omitted, the backend returns every state. */
|
||||
status?: string;
|
||||
applocationid?: number;
|
||||
tenanttype?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export const tenantsApi = {
|
||||
/**
|
||||
* Every tenant on the platform. Deliberately unscoped — this is the
|
||||
* Nearle Admin's list, and the backend treats it as the platform-operator
|
||||
* endpoint rather than a tenant-scoped one.
|
||||
*/
|
||||
listAll: (query: TenantListQuery = {}) =>
|
||||
api.list<TenantInfo>(`${WEB}/tenants/getalltenants`, {
|
||||
pageno: query.pageno ?? 1,
|
||||
pagesize: query.pagesize ?? 100,
|
||||
status: query.status,
|
||||
applocationid: query.applocationid,
|
||||
tenanttype: query.tenanttype,
|
||||
keyword: query.keyword,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Tenants by approval state — the only way to see the ones awaiting it.
|
||||
*
|
||||
* `status=pending` is not a status at all: the handler branches on the word
|
||||
* and queries `approved = 0` instead (`tenantRepository.go:45-77`). Anything
|
||||
* else means `approved = 1 AND status = ?`. So an unapproved merchant is
|
||||
* invisible to every other endpoint, including `getalltenants`.
|
||||
*
|
||||
* Nothing can approve one over HTTP. `approved` is writable only at creation,
|
||||
* so this list is a queue to work from, not one to act on.
|
||||
*/
|
||||
byApproval: (status: 'pending' | 'Active' | 'InActive', keyword?: string) =>
|
||||
api.list<TenantInfo>(`${WEB}/tenants/search`, { status, keyword }),
|
||||
|
||||
/** Branches under one tenant. `tenantid` is required — omit it and it 400s. */
|
||||
locations: (tenantid: number) =>
|
||||
api.list<TenantLocation>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
|
||||
|
||||
search: (keyword: string) =>
|
||||
api.list<TenantInfo>(`${WEB}/tenants/searchbykeyword`, { keyword }),
|
||||
|
||||
/**
|
||||
* Provisions the enterprise, its first outlet, and the primary Administrator
|
||||
* account — one transaction writing `tenants`, `ordersequences`, `app_users`
|
||||
* (roleid 1, configid forced to 1), `customers`, `customerlocations` and
|
||||
* `tenantcustomers`.
|
||||
*
|
||||
* `createtenantuser`, NOT `createtenantlocation`. The latter takes a
|
||||
* `Tenantlocations` and writes a BRANCH under a tenant that already exists —
|
||||
* pointing the merchant form at it created an outlet and no merchant.
|
||||
*
|
||||
* The primary outlet is NESTED. The backend reads it off
|
||||
* `Tenants.Tenantlocations` and creates it in the same transaction, so a
|
||||
* tenant can never exist without somewhere to trade from.
|
||||
*/
|
||||
createTenant: (body: CreateTenantRequest) =>
|
||||
api.post<TenantInfo>(`${WEB}/tenants/createtenantuser`, toTenantBody(body)),
|
||||
|
||||
/**
|
||||
* Commissions a branch, and gives it somebody to run it.
|
||||
*
|
||||
* Pass `operatorid` to place a person you have already hired. Without it the
|
||||
* backend spawns a login named after the shop, as it always did — kept so
|
||||
* nothing existing changes, but the named person is the better path.
|
||||
*
|
||||
* `createtenantlocation`, not `createlocation`: only this one returns the
|
||||
* created row, and the new `locationid` is what a QR code and every
|
||||
* follow-up write need. `createlocation` answers 201 with a message and no
|
||||
* `details` at all.
|
||||
*/
|
||||
createBranch: (body: CreateBranchRequest) =>
|
||||
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
|
||||
|
||||
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
|
||||
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
|
||||
|
||||
/**
|
||||
* A merchant editing their own business record.
|
||||
*
|
||||
* The first write path `tenants` has ever had. Before it, everything about a
|
||||
* shop — its name, its photograph, its licence, how to reach it — was set
|
||||
* once at onboarding by a Nearle Admin and could never be changed by anyone.
|
||||
*
|
||||
* Only merchant-owned columns are written; the backend keeps the allowlist
|
||||
* and ignores the rest, so `approved`, `status`, `partnerid` and the billing
|
||||
* fields cannot be set from here even if a caller sends them. Anything
|
||||
* omitted is left alone rather than blanked.
|
||||
*/
|
||||
updateProfile: (body: { tenantid: number } & Partial<TenantInfo>) =>
|
||||
api.put<unknown>(`${WEB}/tenants/updatetenant`, body),
|
||||
|
||||
/**
|
||||
* Which delivery partner supplies this merchant's riders.
|
||||
*
|
||||
* Its own endpoint, not a field on `updateProfile`: `partnerid` is kept out
|
||||
* of the merchant-editable allowlist on purpose, because a merchant who could
|
||||
* set it would move themselves under another partner's riders and billing.
|
||||
*
|
||||
* `partnerid: 0` is a real instruction — it means "this merchant uses their
|
||||
* own riders" — and the server reads it as sent rather than as absent.
|
||||
*/
|
||||
assignPartner: (tenantid: number, partnerid: number) =>
|
||||
api.put<unknown>(`${WEB}/tenants/assignpartner`, { tenantid, partnerid }),
|
||||
|
||||
/**
|
||||
* One business, by id — how a store login reads its own record.
|
||||
*
|
||||
* Not `listAll`. That is `getalltenants`, paginated over 262 merchants, so a
|
||||
* shop on page two was simply absent and a profile screen built on it would
|
||||
* show nothing for no visible reason.
|
||||
*/
|
||||
byId: (tenantid: number) => api.get<TenantInfo>(`${WEB}/tenants/gettenantinfo`, { tenantid }),
|
||||
|
||||
/**
|
||||
* Somebody editing their own name, mobile or email.
|
||||
*
|
||||
* Not `users/update`. That one writes whatever struct it is handed and checks
|
||||
* only the userid — no tenant, no guard on role or branch — so a self-service
|
||||
* form built on it would let a branch user promote themselves or move shop.
|
||||
* This is scoped to the caller's own account AND business, and writes
|
||||
* identity fields only.
|
||||
*/
|
||||
updateOwnProfile: (body: {
|
||||
userid: number;
|
||||
tenantid: number;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
contactno?: string;
|
||||
email?: string;
|
||||
}) => api.put<unknown>(`${WEB}/tenants/updateownprofile`, body),
|
||||
};
|
||||
|
||||
/**
|
||||
* The merchant form, in the shape `models.Tenants` expects.
|
||||
*
|
||||
* `configid` and `applocationid` are sent because the account this call spawns
|
||||
* is looked up by `configid` at every sign-in, and `applocationid` is the city
|
||||
* the tenant trades in. `approved: 1` and `status: 'Active'` are set here
|
||||
* because they can only ever be set here — there is no update or approve
|
||||
* endpoint, so a tenant created unapproved stays unapproved forever.
|
||||
*/
|
||||
function toTenantBody(form: CreateTenantRequest): Record<string, unknown> {
|
||||
return {
|
||||
tenantname: form.tenantname,
|
||||
companyname: form.companyname,
|
||||
primaryemail: form.primaryemail,
|
||||
primarycontact: form.primarycontact,
|
||||
// Written to `tenants.firstname` and copied onto the admin login the same
|
||||
// transaction creates — see the note on the field.
|
||||
firstname: (form.firstname ?? '').trim(),
|
||||
categoryid: form.categoryid,
|
||||
subcategoryid: form.subcategoryid ?? 0,
|
||||
address: form.address,
|
||||
suburb: form.suburb ?? '',
|
||||
city: form.city,
|
||||
state: form.state,
|
||||
postcode: form.postcode,
|
||||
latitude: form.latitude ?? '',
|
||||
longitude: form.longitude ?? '',
|
||||
configid: 1,
|
||||
moduleid: form.moduleid ?? 2,
|
||||
applocationid: form.applocationid ?? 1,
|
||||
approved: 1,
|
||||
status: form.status ?? 'Active',
|
||||
// The primary outlet, created in the same transaction. Its address
|
||||
// defaults to the tenant's — a merchant's first shop is at the address
|
||||
// they just typed far more often than not, and it can be edited after.
|
||||
tenantlocations: {
|
||||
locationname: form.locationname,
|
||||
email: form.primaryemail,
|
||||
contactno: form.primarycontact,
|
||||
address: form.address,
|
||||
suburb: form.suburb ?? '',
|
||||
city: form.city,
|
||||
state: form.state,
|
||||
postcode: form.postcode,
|
||||
latitude: form.latitude ?? '',
|
||||
longitude: form.longitude ?? '',
|
||||
applocationid: form.applocationid ?? 1,
|
||||
status: 'Active',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One row of the `app_category` master — the business categories a tenant picks from. */
|
||||
export interface AppCategory {
|
||||
categoryid: number;
|
||||
categoryname: string;
|
||||
}
|
||||
|
||||
export const utilsApi = {
|
||||
/**
|
||||
* The business-category master.
|
||||
*
|
||||
* Read rather than hardcoded. The old console typed four values into the
|
||||
* form and never called this, which means a category added to the master is
|
||||
* invisible to onboarding until someone edits the frontend.
|
||||
*/
|
||||
appCategories: () => api.list<AppCategory>(`${WEB}/utils/getappcategories`),
|
||||
|
||||
/**
|
||||
* The delivery regions — Coimbatore, Madurai, Nagercoil today.
|
||||
*
|
||||
* `applocationid` is REQUIRED by the handler and 0 is how you ask for all of
|
||||
* them; omitting it answers 400 "Invalid applocationid", which reads as a
|
||||
* broken request rather than a missing default.
|
||||
*/
|
||||
appLocations: (applocationid = 0) =>
|
||||
api.list<AppLocation>(`${WEB}/utils/getapplocations`, { applocationid }),
|
||||
};
|
||||
1003
src/api/types.ts
Normal file
1003
src/api/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
100
src/api/uploads.test.ts
Normal file
100
src/api/uploads.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The receipt, and the two things it has to get right.
|
||||
*
|
||||
* A receipt exists because the ingest service's batch id is the only credential
|
||||
* for reading a result back, it is handed out once, and an unreviewed drop is
|
||||
* deleted after seven days. So the tests that matter are about not losing that
|
||||
* window, and about the label their admin reads when deciding whether to
|
||||
* approve the file.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { buildSender, daysUntilExpiry, DROP_RETENTION_DAYS, type UploadReceipt } from './uploads';
|
||||
|
||||
function receipt(overrides: Partial<UploadReceipt> = {}): UploadReceipt {
|
||||
return {
|
||||
uploadid: 1,
|
||||
tenantid: 1141,
|
||||
locationid: 1180,
|
||||
categoryid: 2,
|
||||
batchid: '49a82536866a483a9189954d3c749243',
|
||||
runid: '',
|
||||
filename: 'kmart-opening.xlsx',
|
||||
sender: 'Kmart · Peelamedu · abhishek',
|
||||
uploadedby: 1475,
|
||||
uploadedname: 'abhishek',
|
||||
rowcount: 20,
|
||||
laststatus: 'pending',
|
||||
inserted: 0,
|
||||
backfilled: 0,
|
||||
skipped: 0,
|
||||
rejected: 0,
|
||||
shelvedcount: 0,
|
||||
skippedcount: 0,
|
||||
shelvedat: null,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString();
|
||||
|
||||
test('a drop uploaded today has the full window left', () => {
|
||||
assert.equal(daysUntilExpiry(receipt()), DROP_RETENTION_DAYS);
|
||||
});
|
||||
|
||||
test('the window closes as the drop sits unreviewed', () => {
|
||||
assert.equal(daysUntilExpiry(receipt({ created: daysAgo(5) })), 2);
|
||||
});
|
||||
|
||||
// Never negative. A drop past the deadline is gone, and "-3 days left" would
|
||||
// read as a countdown that is still running.
|
||||
test('an expired drop reports zero, not a negative', () => {
|
||||
assert.equal(daysUntilExpiry(receipt({ created: daysAgo(30) })), 0);
|
||||
});
|
||||
|
||||
/*
|
||||
Retention applies to a drop nobody acted on. Once an admin releases it the run
|
||||
is the record and the drop's own expiry is irrelevant — showing a countdown on
|
||||
a released upload would push someone to chase a deadline that has already been
|
||||
met.
|
||||
*/
|
||||
test('a released drop has no expiry to report', () => {
|
||||
assert.equal(daysUntilExpiry(receipt({ runid: '8dcef8a2ad94', laststatus: 'retired' })), null);
|
||||
assert.equal(daysUntilExpiry(receipt({ laststatus: 'done' })), null);
|
||||
});
|
||||
|
||||
test('a receipt with an unreadable date reports nothing rather than guessing', () => {
|
||||
assert.equal(daysUntilExpiry(receipt({ created: 'not a date' })), null);
|
||||
});
|
||||
|
||||
/*
|
||||
The sender label. Free text on their side, capped at 60 characters, and shown to
|
||||
the admin who decides whether to run the file — so it has to identify the shop,
|
||||
not the console.
|
||||
*/
|
||||
test('the sender names the merchant, the branch and the person', () => {
|
||||
assert.equal(
|
||||
buildSender({ tenantname: 'Kmart', locationname: 'Peelamedu', username: 'abhishek' }),
|
||||
'Kmart · Peelamedu · abhishek',
|
||||
);
|
||||
});
|
||||
|
||||
// The tenant name is truncated, not the whole label. Cutting the tail would
|
||||
// drop the branch and the person — the two parts that say WHICH shelf and WHO —
|
||||
// and leave only a long restaurant name that identifies neither.
|
||||
test('a long merchant name is trimmed so the branch and person survive', () => {
|
||||
const sender = buildSender({
|
||||
tenantname: 'Ninhao The New Age Chinese Restaurant',
|
||||
locationname: 'Race Course',
|
||||
username: 'abhishek',
|
||||
});
|
||||
assert.ok(sender.length <= 60, `sender was ${sender.length} chars: ${sender}`);
|
||||
assert.ok(sender.includes('Race Course'), sender);
|
||||
assert.ok(sender.includes('abhishek'), sender);
|
||||
});
|
||||
|
||||
test('a sender with nothing to say still labels the console', () => {
|
||||
assert.equal(buildSender({}), 'nearle-console');
|
||||
});
|
||||
220
src/api/uploads.ts
Normal file
220
src/api/uploads.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Receipts for spreadsheets sent to the catalogue ingest service.
|
||||
*
|
||||
* This is OUR record, in Fiesta — not the ingest service's. The two are read
|
||||
* together and neither is redundant:
|
||||
*
|
||||
* - **Fiesta** knows the batch id, which shop the sheet was for, who sent it,
|
||||
* and whether the products reached that shop's shelf. None of which the
|
||||
* ingest service has any concept of — it writes the shared global
|
||||
* catalogue and has no tenant and no branch.
|
||||
* - **The ingest service** knows what became of the drop, and is the only
|
||||
* authority on that.
|
||||
*
|
||||
* Fiesta's status columns are a CACHE of the second, written by whichever
|
||||
* browser last polled. They exist so a list of twenty receipts renders without
|
||||
* twenty network calls to a host that spends minutes per batch; the live read
|
||||
* is what any single receipt is judged by.
|
||||
*
|
||||
* ── Why the receipt has to exist at all ──────────────────────────────────────
|
||||
*
|
||||
* Three facts from the ingest service's own documentation, and any one of them
|
||||
* would be enough:
|
||||
*
|
||||
* 1. **The batch id is the credential.** `GET /api/uploads/catalog/{id}` is
|
||||
* anonymous by design — holding the id is the proof of having sent the
|
||||
* drop. Handed out once, to one browser. Lose it and the result is
|
||||
* unreadable by anyone, including whoever uploaded the file.
|
||||
* 2. **An unreviewed drop is deleted after seven days.** Nothing runs on
|
||||
* arrival; a drop waits for an admin to press Start. If nobody does, the
|
||||
* evidence expires.
|
||||
* 3. **We cannot list our own drops.** `GET /api/uploads/catalog` is scoped to
|
||||
* the credential that sent them, production has no API keys configured at
|
||||
* all, and the only account that could read it is a superuser over their
|
||||
* entire application.
|
||||
*/
|
||||
|
||||
import { api, WEB } from './client';
|
||||
|
||||
/**
|
||||
* One upload, as Fiesta stores it.
|
||||
*
|
||||
* The two count groups are deliberately not merged. `inserted` and its
|
||||
* neighbours are the ingest service's — products in the GLOBAL catalogue, which
|
||||
* every merchant shares and which therefore carries no price and no stock.
|
||||
* `shelvedcount` is ours: priced, on a branch's shelf, with opening stock
|
||||
* recorded. A product can be in the first and not the second, and reporting it
|
||||
* as "added" would tell a shopkeeper they can sell something nobody can buy.
|
||||
*/
|
||||
export interface UploadReceipt {
|
||||
uploadid: number;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
categoryid: number;
|
||||
|
||||
/** The drop id. The only field here that cannot be reconstructed. */
|
||||
batchid: string;
|
||||
/** The run an admin released the drop into, once they have. */
|
||||
runid: string;
|
||||
|
||||
filename: string;
|
||||
/** The label their review inbox shows. */
|
||||
sender: string;
|
||||
|
||||
uploadedby: number;
|
||||
uploadedname: string;
|
||||
/** Rows we parsed before sending — independent of anything the service says. */
|
||||
rowcount: number;
|
||||
/**
|
||||
* The parsed sheet as JSON, or empty when it was never stored.
|
||||
*
|
||||
* Empty is the interesting case: it means the prices and opening stock are
|
||||
* gone, and the only way to shelve this upload is for somebody to hand the
|
||||
* file over again. Receipts written before this column existed are all in
|
||||
* that state.
|
||||
*/
|
||||
sheetrows?: string;
|
||||
|
||||
/* ── Cached from the ingest service ──────────────────────────────────── */
|
||||
laststatus: string;
|
||||
inserted: number;
|
||||
backfilled: number;
|
||||
skipped: number;
|
||||
rejected: number;
|
||||
|
||||
/* ── Ours ────────────────────────────────────────────────────────────── */
|
||||
shelvedcount: number;
|
||||
skippedcount: number;
|
||||
shelvedat: string | null;
|
||||
|
||||
created: string;
|
||||
updated: string;
|
||||
|
||||
/** Joined for display; a receipt outlives the page that made it. */
|
||||
tenantname?: string;
|
||||
locationname?: string;
|
||||
}
|
||||
|
||||
export interface RecordUploadBody {
|
||||
/**
|
||||
* The parsed sheet as JSON — SKU, price and opening stock per row.
|
||||
*
|
||||
* Stored so the shelving step can run later, from the Uploads page, without
|
||||
* the original file or the tab that sent it. The prices and opening stock
|
||||
* exist nowhere else: the ingest service's catalogue is shared by every
|
||||
* merchant and carries neither.
|
||||
*/
|
||||
sheetrows?: string;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
categoryid: number;
|
||||
batchid: string;
|
||||
filename: string;
|
||||
sender: string;
|
||||
uploadedby: number;
|
||||
uploadedname: string;
|
||||
rowcount: number;
|
||||
laststatus?: string;
|
||||
}
|
||||
|
||||
export interface UploadQuery {
|
||||
/** 0 or omitted means every tenant — how a Nearle Admin sees the platform. */
|
||||
tenantid?: number;
|
||||
locationid?: number;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the ingest service keeps a drop nobody has acted on.
|
||||
*
|
||||
* `BATCH_RETENTION_DAYS` on their side. Worth showing rather than discovering:
|
||||
* a drop that expires unreviewed leaves no trace at either end, and the only
|
||||
* remedy — asking an admin to release it — has to happen before the deadline.
|
||||
*/
|
||||
export const DROP_RETENTION_DAYS = 7;
|
||||
|
||||
/** Days left before an unreviewed drop is deleted; null once it has run. */
|
||||
export function daysUntilExpiry(receipt: UploadReceipt): number | null {
|
||||
// Only a drop still sitting in the review inbox expires. Once released, the
|
||||
// run is the record and retention no longer applies to it.
|
||||
if (receipt.runid || receipt.laststatus !== 'pending') return null;
|
||||
const created = Date.parse(receipt.created);
|
||||
if (Number.isNaN(created)) return null;
|
||||
const elapsedDays = (Date.now() - created) / 86_400_000;
|
||||
return Math.max(0, Math.ceil(DROP_RETENTION_DAYS - elapsedDays));
|
||||
}
|
||||
|
||||
/**
|
||||
* The label the ingest service's admin sees in their review inbox.
|
||||
*
|
||||
* Their field is free text capped at 60 characters, and until now every upload
|
||||
* from this console arrived as the same constant — so an admin deciding what to
|
||||
* approve could not tell one merchant's sheet from another's.
|
||||
*
|
||||
* Safe to make specific precisely because we never send a credential. Their
|
||||
* ownership filter matches `sender` EXACTLY, and a run an admin assembles from
|
||||
* several drops carries a joined list ("alice, bob") — so a credentialed caller
|
||||
* gets a 404 on a run containing their own file. We read anonymously, holding
|
||||
* the id, which is what their documentation tells integrators to do.
|
||||
*
|
||||
* The tenant name is truncated rather than the whole label, so the branch and
|
||||
* the person survive: "Ninhao The New Age Chinese Restaurant" is 36 characters
|
||||
* on its own and would otherwise push everything identifying off the end.
|
||||
*/
|
||||
export function buildSender(parts: {
|
||||
tenantname?: string;
|
||||
locationname?: string;
|
||||
username?: string;
|
||||
}): string {
|
||||
const tenant = (parts.tenantname ?? '').trim().slice(0, 24);
|
||||
const label = [tenant, (parts.locationname ?? '').trim(), (parts.username ?? '').trim()]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return (label || 'nearle-console').slice(0, 60);
|
||||
}
|
||||
|
||||
export const uploadsApi = {
|
||||
/**
|
||||
* Store the receipt. Called the instant the drop is accepted, before polling.
|
||||
*
|
||||
* That timing is the whole point: it is the one moment the batch id is
|
||||
* guaranteed to exist and guaranteed not to have been lost to a closed tab.
|
||||
* Idempotent on `batchid` server-side, so a retry or a second tab is safe.
|
||||
*/
|
||||
record: (body: RecordUploadBody) => api.post<UploadReceipt>(`${WEB}/uploads/record`, body),
|
||||
|
||||
list: (query: UploadQuery = {}) =>
|
||||
api.list<UploadReceipt>(`${WEB}/uploads/list`, {
|
||||
tenantid: query.tenantid ?? 0,
|
||||
locationid: query.locationid ?? 0,
|
||||
pageno: query.pageno ?? 1,
|
||||
pagesize: query.pagesize ?? 50,
|
||||
}),
|
||||
|
||||
/** Cache what the ingest service last reported, so the next reader need not wait. */
|
||||
updateStatus: (body: {
|
||||
batchid: string;
|
||||
laststatus: string;
|
||||
runid?: string;
|
||||
inserted?: number;
|
||||
backfilled?: number;
|
||||
skipped?: number;
|
||||
rejected?: number;
|
||||
}) => api.put<unknown>(`${WEB}/uploads/update`, body),
|
||||
|
||||
/**
|
||||
* Supply the prices and opening stock for a receipt that has none.
|
||||
*
|
||||
* The rescue path. A receipt filed before the sheet was stored cannot be
|
||||
* shelved from anywhere, because the ingest service holds a catalogue every
|
||||
* merchant shares and it carries neither figure — so the file has to come
|
||||
* back. Sent as JSON so the shelving can then run without it again.
|
||||
*/
|
||||
attachSheet: (body: { batchid: string; sheetrows: string }) =>
|
||||
api.put<unknown>(`/uploads/sheet`, body),
|
||||
|
||||
/** Record the other half: priced, shelved and stocked at a branch. */
|
||||
markShelved: (body: { batchid: string; shelved: number; skipped: number }) =>
|
||||
api.put<unknown>(`${WEB}/uploads/shelved`, body),
|
||||
};
|
||||
Reference in New Issue
Block a user