rider creation

This commit is contained in:
2026-09-04 16:03:46 +05:30
parent 416c50755b
commit e1a16377ea
17 changed files with 2259 additions and 33 deletions

View File

@@ -35,13 +35,20 @@ export const RIDER_MESSAGE = {
export interface RiderQuery {
/**
* The delivery region. This is the scope that works.
* The delivery region, and for now the only scope that finds anybody.
*
* `tenantid` is also accepted and returns nothing: the filter is
* `app_users.tenantid`, which is not set on rider accounts. Verified against
* production — `?tenantid=1135` gives an empty list while `?applocationid=1`
* gives the rider working that tenant's shops. Passing the tenant would have
* produced an empty picker with no error to explain it.
* `tenantid` is accepted too and the query is sound — it just matches nothing
* yet, because `app_users.tenantid` was never filled in for a rider. Riders
* hired through this console DO carry one, so tenant scope starts working the
* moment a merchant has their own.
*
* It is not the scope used here, and that is deliberate: production has 84
* riders on applocation 1 and none of them has a tenant, so switching today
* would empty the picker for everybody. Revisit once merchants have hired
* their own — preferring tenant and falling back to region.
*
* Note what region means: a CITY. Until then an operator is offered every
* on-duty rider in Coimbatore, including other merchants'.
*/
applocationid: number;
}
@@ -135,3 +142,114 @@ export interface UpdateDelivery {
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;
partnerid?: 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;
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;
applocationid?: number;
contactno?: string;
city?: string;
}
export interface RiderShift {
shiftid: number;
shiftname?: string;
starttime?: string;
endtime?: string;
shifthours?: 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. `tenantid` travels as a param — the backend ignores it in the body. */
create: (tenantid: number, rider: NewRider) =>
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { tenantid }),
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 }),
/** Delivery partners a rider can ride for. */
partners: (applocationid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
};

118
src/api/nutrition.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* 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;
}
}
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: (brand: string, imageId: string) =>
read<NutritionScore>(
`/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`,
),
};
/** 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);
}

View File

@@ -20,7 +20,7 @@
* know about it.
*/
import { api, WEB } from './client';
import { api, MOB, WEB } from './client';
import type {
ImportCatalogueProductRequest,
Product,
@@ -281,3 +281,66 @@ export async function importSheetProducts(
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),
};

View File

@@ -211,8 +211,31 @@ export interface Product {
productsku?: string;
brandid?: number;
productbrand?: string;
/**
* The stable global-catalogue key, carried across by the import.
*
* Not `catalogueid`, which is renumbered on every re-scrape — 11 of 19 links
* were already broken by that. This is what joins a tenant's product back to
* the catalogue, and it is also the key the health-score service uses.
*
* Empty for anything typed in or imported from a sheet, which is most of the
* catalogue: measured 4 Sep 2026, R mart carries it on 21 products of 28,
* Suriya Store on 2 of 8, K mart on none.
*/
imageid?: string;
productunit?: string;
unitvalue?: string;
/**
* The size label, present only on rows from `getproductbyvariant`.
*
* What a shopper taps in the size picker — "500g", not the whole product
* name. The backend derives the PARENT's own label from
* `unitvalue + productunit` and falls back to the product name when both are
* blank, so a product imported without a unit shows its full name in the
* picker. Worth knowing when a picker reads badly: the fix is the product's
* unit, not the link.
*/
variantname?: string;
productcost?: number;
taxamount?: number;
taxpercent?: number;