1623 lines
57 KiB
TypeScript
1623 lines
57 KiB
TypeScript
/**
|
|
* @license
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Fiesta REST client — the merchant-facing `live/api/v1/web/*` surface served by
|
|
* https://fiesta.nearle.app (documented at developer.nearledaily.com under the
|
|
* REST tab). This is the operational backend: order/delivery/location summaries,
|
|
* the deliveries board, riders, stock statements, and customers.
|
|
*
|
|
* Requests go directly to `https://fiesta.nearle.app/*` — Fiesta is CORS-enabled
|
|
* and needs no auth header for these read endpoints, so no dev proxy is required.
|
|
*
|
|
* This sits alongside `./api` (the Hasura/workolik REST surface the dashboard
|
|
* uses). Components should call the TanStack hooks in `./fiestaQueries`, not
|
|
* these functions directly.
|
|
*/
|
|
|
|
const FIESTA_BASE = import.meta.env.VITE_FIESTA_URL || 'https://fiesta.nearle.app/live/api/v1/web';
|
|
const FIESTA_MOB_BASE = import.meta.env.VITE_FIESTA_MOB_URL || 'https://fiesta.nearle.app/live/api/v1/mob';
|
|
|
|
/** Tenant / location scope shared by the merchant console. */
|
|
export const FIESTA_TENANT_ID = 1135;
|
|
export const FIESTA_APPLOCATION_ID = 1;
|
|
/** Primary outlet for this tenant — the one carrying live orders/stock. */
|
|
export const FIESTA_PRIMARY_LOCATION_ID = 1170;
|
|
|
|
export type Row = Record<string, unknown>;
|
|
type QueryParams = Record<string, string | number | undefined | null>;
|
|
|
|
/**
|
|
* The exact payload the nearledaily consumer app expects when its in-app scanner
|
|
* reads a store QR: a JSON object `{"tenantid":N,"locationid":N}`. The app parses
|
|
* this and resolves the outlet from it.
|
|
*
|
|
* IMPORTANT: it must be this JSON shape, NOT a URL — the app rejects a URL with
|
|
* "invalid QR code content". Keep it to exactly these two keys to match the app's
|
|
* schema; extra keys risk strict-schema rejection on the app side.
|
|
*/
|
|
export function buildStoreQrPayload(opts: { tenantid: number; locationid: number }): string {
|
|
return JSON.stringify({ tenantid: opts.tenantid, locationid: opts.locationid });
|
|
}
|
|
|
|
async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
|
|
const qs = new URLSearchParams();
|
|
Object.entries(params).forEach(([k, v]) => {
|
|
// Fiesta requires some params to be present-but-empty (e.g. keyword=), so we
|
|
// keep empty strings and only drop undefined/null.
|
|
if (v !== undefined && v !== null) qs.append(k, String(v));
|
|
});
|
|
const query = qs.toString();
|
|
const res = await fetch(`${FIESTA_BASE}/${endpoint}${query ? `?${query}` : ''}`, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Fiesta ${endpoint} failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
async function fiestaSend<T = unknown>(
|
|
endpoint: string,
|
|
method: 'POST' | 'PUT' | 'DELETE',
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
const res = await fetch(`${FIESTA_BASE}/${endpoint}`, {
|
|
method,
|
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
const json = (await res.json().catch(() => null)) as
|
|
| { message?: string; status?: boolean }
|
|
| null;
|
|
if (!res.ok || (json && json.status === false)) {
|
|
throw new Error(json?.message || `Fiesta ${endpoint} failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
return json as T;
|
|
}
|
|
|
|
/**
|
|
* Fiesta envelopes responses as `{ code, details, message, status }`. `details`
|
|
* is usually an array of rows, sometimes a single object, sometimes null.
|
|
*/
|
|
export function toRows<T = Row>(json: unknown): T[] {
|
|
if (Array.isArray(json)) return json as T[];
|
|
if (json && typeof json === 'object') {
|
|
const d = (json as { details?: unknown }).details;
|
|
if (Array.isArray(d)) return d as T[];
|
|
if (d && typeof d === 'object') return [d as T];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function firstRow<T = Row>(json: unknown): T | null {
|
|
const d = (json as { details?: unknown })?.details;
|
|
if (Array.isArray(d)) return (d.length ? (d[0] as T) : null);
|
|
if (d && typeof d === 'object') return d as T;
|
|
return null;
|
|
}
|
|
|
|
export function num(v: unknown): number {
|
|
const n = typeof v === 'number' ? v : Number(v);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
export const str = (v: unknown): string => (v == null ? '' : String(v));
|
|
|
|
/**
|
|
* Display name for a customer row. `gettenantcustomers` returns `firstname` /
|
|
* `lastname` and has NO `customername` or `name` column, so code that read those
|
|
* fell through to "Unknown Customer" for every customer on the platform. Delivery
|
|
* rows spell the same person `deliverycustomer`, hence the extra fallbacks.
|
|
*/
|
|
export function customerName(r: Row): string {
|
|
const full = `${str(r.firstname).trim()} ${str(r.lastname).trim()}`.trim();
|
|
return (
|
|
full ||
|
|
str(r.deliverycustomer).trim() ||
|
|
str(r.customername).trim() ||
|
|
str(r.name).trim()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The store a customer belongs to. `gettenantcustomers` aliases the
|
|
* `tenantcustomers.locationid` link as `tenantlocationid` — plain `locationid`
|
|
* is NOT in the response, so filtering on it matched nothing. `deliverylocationid`
|
|
* is the saved-address id, a different thing entirely, and must not be used here.
|
|
*/
|
|
export const customerStoreId = (r: Row): number => num(r.tenantlocationid) || num(r.locationid);
|
|
|
|
/** Fiesta date params want a bare `YYYY-MM-DD`. */
|
|
export const ymd = (d: Date) =>
|
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// ORDERS
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export interface FiestaOrderSummary {
|
|
total: number;
|
|
created: number;
|
|
pending: number;
|
|
processing: number;
|
|
delivered: number;
|
|
cancelled: number;
|
|
tenantid?: number;
|
|
tenantname?: string;
|
|
}
|
|
|
|
export interface FiestaLocationRevenue {
|
|
locationid: number;
|
|
locationname: string;
|
|
revenue: number;
|
|
}
|
|
|
|
export interface FiestaRevenueSummary {
|
|
tenantid: number;
|
|
tenantname: string;
|
|
overallrevenue: number;
|
|
locationrevenue: FiestaLocationRevenue[];
|
|
}
|
|
|
|
/** /orders/getrevenuesummary?tenantid=&locationid=&fromdate=&todate= — tenant revenue and location breakdown. */
|
|
export async function getRevenueSummary(opts: {
|
|
tenantid: number;
|
|
fromdate?: string;
|
|
todate?: string;
|
|
locationid?: number;
|
|
}) {
|
|
const res = await fiestaGet('orders/getrevenuesummary', opts);
|
|
return firstRow<{
|
|
tenantid: number;
|
|
tenantname: string;
|
|
overallrevenue: number;
|
|
locationrevenue: Array<{
|
|
locationid: number;
|
|
locationname: string;
|
|
revenue: number;
|
|
}>;
|
|
}>(res);
|
|
}
|
|
|
|
|
|
|
|
/** /orders/getordersummary?tenantid=&locationid=&fromdate=&todate= — flat order counts. */
|
|
export async function getOrderSummary(
|
|
tenantid: number,
|
|
fromdate: string,
|
|
todate: string,
|
|
locationid?: number,
|
|
): Promise<FiestaOrderSummary | null> {
|
|
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', {
|
|
tenantid,
|
|
locationid,
|
|
applocationid: locationid, // send both to bypass backend bugs
|
|
fromdate,
|
|
todate
|
|
}));
|
|
if (!row) return null;
|
|
return {
|
|
total: num(row.total),
|
|
created: num(row.created),
|
|
pending: num(row.pending),
|
|
processing: num(row.processing),
|
|
delivered: num(row.delivered),
|
|
cancelled: num(row.cancelled),
|
|
tenantid: row.tenantid != null ? num(row.tenantid) : undefined,
|
|
tenantname: typeof row.tenantname === 'string' ? row.tenantname : undefined,
|
|
};
|
|
}
|
|
|
|
export interface FiestaLocationSummary {
|
|
locationid: number;
|
|
locationname: string;
|
|
total: number;
|
|
created: number;
|
|
pending: number;
|
|
processing: number;
|
|
delivered: number;
|
|
cancelled: number;
|
|
}
|
|
|
|
/** /orders/getlocationsummary?tenantid=&fromdate=&todate= — per-outlet order rollup. */
|
|
export async function getLocationSummary(tenantid: number, fromdate?: string, todate?: string): Promise<FiestaLocationSummary[]> {
|
|
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid, fromdate, todate })).map((r) => ({
|
|
locationid: num(r.locationid),
|
|
locationname: str(r.locationname),
|
|
total: num(r.total),
|
|
created: num(r.created),
|
|
pending: num(r.pending),
|
|
processing: num(r.processing),
|
|
delivered: num(r.delivered),
|
|
cancelled: num(r.cancelled),
|
|
}));
|
|
}
|
|
|
|
/** /orders/getorderinsight?tenantid= — per-location monthly order counts. */
|
|
export async function getOrderInsight(tenantid: number): Promise<Row[]> {
|
|
return toRows(await fiestaGet('orders/getorderinsight', { tenantid }));
|
|
}
|
|
|
|
/** /orders/getorders?tenantid=&locationid=&applocationid=&status=&fromdate=&todate=&keyword=&pageno=&pagesize= — orders board. */
|
|
export async function getOrders(opts: {
|
|
tenantid: number;
|
|
status: string;
|
|
fromdate: string;
|
|
todate: string;
|
|
locationid?: number;
|
|
applocationid?: number;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('orders/getorders', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
applocationid: opts.applocationid,
|
|
status: opts.status,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
keyword: opts.keyword,
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 20,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /orders/gettimeseries?tenantid=&locationid=&granularity=&fromdate=&todate= — time-series chart data */
|
|
export async function getTimeSeries(opts: {
|
|
tenantid: number;
|
|
granularity: 'day' | 'month' | 'year';
|
|
fromdate: string;
|
|
todate: string;
|
|
locationid?: number;
|
|
}) {
|
|
const res = await fiestaGet('orders/gettimeseries', opts);
|
|
return toRows<{
|
|
label: string;
|
|
orders: number;
|
|
revenue: number;
|
|
delivered: number;
|
|
cancelled: number;
|
|
activeskus: number;
|
|
}>(res);
|
|
}
|
|
|
|
/** /orders/getorderdetails?orderheaderid= — line items for a single order. */
|
|
export async function getOrderDetails(orderheaderid: number | string): Promise<Row[]> {
|
|
let cleanId = String(orderheaderid).trim();
|
|
if (cleanId.toUpperCase().startsWith('DLV-')) {
|
|
cleanId = cleanId.substring(4);
|
|
}
|
|
cleanId = cleanId.split('-')[0];
|
|
const numericId = Number(cleanId);
|
|
const finalId = Number.isInteger(numericId) && numericId > 0 ? numericId : orderheaderid;
|
|
|
|
return toRows(await fiestaGet('orders/getorderdetails', { orderheaderid: finalId }));
|
|
}
|
|
|
|
/** /orders/getorders?customerid=&status=&pageno=&pagesize= — one customer's order history. */
|
|
export async function getCustomerOrders(opts: {
|
|
customerid: number | string;
|
|
status?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('orders/getorders', {
|
|
customerid: opts.customerid,
|
|
status: opts.status ?? '',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 20,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// DELIVERIES
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export interface FiestaDeliverySummary {
|
|
total: number;
|
|
created: number;
|
|
pending: number;
|
|
accepted: number;
|
|
arrived: number;
|
|
picked: number;
|
|
active: number;
|
|
delivered: number;
|
|
cancelled: number;
|
|
}
|
|
|
|
/** /deliveries/deliverysummary?tenantid=&applocationid=&locationid=&fromdate=&todate= — dispatch counts. */
|
|
export async function getDeliverySummary(opts: {
|
|
tenantid: number;
|
|
applocationid?: number;
|
|
locationid?: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
}): Promise<FiestaDeliverySummary | null> {
|
|
const row = firstRow<Row>(
|
|
await fiestaGet('deliveries/deliverysummary', {
|
|
tenantid: opts.tenantid,
|
|
applocationid: opts.applocationid, // only sent when provided (no forced default)
|
|
locationid: opts.locationid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
}),
|
|
);
|
|
if (!row) return null;
|
|
return {
|
|
total: num(row.total),
|
|
created: num(row.created),
|
|
pending: num(row.pending),
|
|
accepted: num(row.accepted),
|
|
arrived: num(row.arrived),
|
|
picked: num(row.picked),
|
|
active: num(row.active),
|
|
delivered: num(row.delivered),
|
|
cancelled: num(row.cancelled),
|
|
};
|
|
}
|
|
|
|
/** /deliveries/getdeliveries?tenantid=&applocationid=&locationid=&status=&fromdate=&todate=&keyword=&pageno=&pagesize= — the master deliveries board. */
|
|
export async function getDeliveries(opts: {
|
|
tenantid: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
status?: string;
|
|
locationid?: number;
|
|
applocationid?: number;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
const pagesize = opts.pagesize ?? 200;
|
|
const fetchPage = async (pageno: number) =>
|
|
toRows(
|
|
await fiestaGet('deliveries/getdeliveries', {
|
|
tenantid: opts.tenantid,
|
|
// NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid
|
|
// filter on THIS endpoint is broken: passing a real outlet id returns []
|
|
// (it doesn't match against the row's own `locationid`), even though
|
|
// deliverysummary honours the same id and the rows clearly carry it. So we
|
|
// fetch tenant-wide here and scope by locationid client-side below; the KPI
|
|
// strip (deliverysummary) keeps using the working server-side filter.
|
|
applocationid: opts.applocationid,
|
|
// The backend treats `status` as a LITERAL orderstatus filter — passing
|
|
// 'all' matches nothing (returns []). Send empty to fetch every status and
|
|
// let the board filter client-side by its status tabs.
|
|
status: !opts.status || opts.status === 'all' ? '' : opts.status,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
keyword: opts.keyword,
|
|
pageno,
|
|
pagesize,
|
|
}),
|
|
);
|
|
|
|
let rows: Row[];
|
|
if (opts.pageno) {
|
|
// An explicit page was asked for — honour it and don't walk the rest.
|
|
rows = await fetchPage(opts.pageno);
|
|
} else {
|
|
// Walk every page. The endpoint has no total-count field, so a short page is
|
|
// the only end-of-data signal. Previously this fetched page 1 only, which
|
|
// silently dropped delivery 201+ for a busy tenant-wide day — the dispatch
|
|
// board looked complete while missing stops. MAX_PAGES caps a runaway loop
|
|
// if the backend ever ignores `pageno` and keeps returning full pages.
|
|
const MAX_PAGES = 25;
|
|
rows = [];
|
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
const batch = await fetchPage(page);
|
|
rows.push(...batch);
|
|
if (batch.length < pagesize) break;
|
|
}
|
|
}
|
|
return opts.locationid ? rows.filter((r) => num(r.locationid) === opts.locationid) : rows;
|
|
}
|
|
|
|
/** /deliveries/getdeliveryinsight?tenantid= — daily delivery insight. */
|
|
export async function getDeliveryInsight(tenantid: number): Promise<Row[]> {
|
|
return toRows(await fiestaGet('deliveries/getdeliveryinsight', { tenantid }));
|
|
}
|
|
|
|
/** /deliveries/getdeliveryreport?tenantid=&applocationid=&partnerid=&userid=&fromdate=&todate= —
|
|
* deliveries financial report summary (per the endpoint sheet). */
|
|
export async function getDeliveryReport(opts: {
|
|
tenantid: number;
|
|
applocationid?: number;
|
|
partnerid?: number;
|
|
userid?: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('deliveries/getdeliveryreport', {
|
|
tenantid: opts.tenantid,
|
|
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
|
partnerid: opts.partnerid,
|
|
userid: opts.userid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /partners/getfleetsummary?applocationid=&partnerid=&tenantid=&fromdate=&todate= —
|
|
* fleet rider summary metrics (per the endpoint sheet). */
|
|
export async function getFleetSummary(opts: {
|
|
applocationid?: number;
|
|
partnerid?: number;
|
|
tenantid: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('partners/getfleetsummary', {
|
|
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
|
partnerid: opts.partnerid,
|
|
tenantid: opts.tenantid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** `YYYY-MM-DD HH:mm:ss` — the timestamp format the delivery endpoints expect. */
|
|
function nowStamp(): string {
|
|
const d = new Date();
|
|
const p = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
}
|
|
|
|
/**
|
|
* Build a delivery record from a getorders row for POST /createdeliveries. The
|
|
* backend keys the order (orderheaderid) and the rider (userid), copies the
|
|
* pickup/drop snapshot, and must carry the SAME tenant/partner/applocation as
|
|
* the order so the assignment is valid. Order field names map 1:1 (Go's JSON
|
|
* decode is case-insensitive, so `pickupaddress` satisfies `Pickupaddress`).
|
|
*/
|
|
function deliveryFromOrder(o: Row, userid: number, assigntime: string): Row {
|
|
return {
|
|
orderheaderid: num(o.orderheaderid),
|
|
orderid: str(o.orderid),
|
|
applocationid: num(o.applocationid),
|
|
configid: num(o.configid) || 1,
|
|
partnerid: num(o.partnerid),
|
|
tenantid: num(o.tenantid),
|
|
moduleid: num(o.moduleid),
|
|
locationid: num(o.locationid),
|
|
categoryid: num(o.categoryid),
|
|
subcategoryid: num(o.subcategoryid),
|
|
userid, // the assigned rider
|
|
customerid: num(o.customerid),
|
|
orderstatus: 'pending',
|
|
assigntime,
|
|
// The Orders API exposes the scheduled delivery date as `deliverytime` (there
|
|
// is no `deliverydate` on an order row). Copy it through so the new delivery
|
|
// lands in the Deliveries board's date window — falling back to the order date
|
|
// and finally the assign timestamp so the row is never written date-less
|
|
// (a date-less delivery is excluded by getdeliveries' from/to filter).
|
|
deliverydate: str(o.deliverydate) || str(o.deliverytime) || str(o.orderdate) || assigntime,
|
|
itemcount: num(o.itemcount),
|
|
orderamount: num(o.orderamount) || num(o.deliveryamt),
|
|
deliveryamt: num(o.deliveryamt),
|
|
deliverycharges: num(o.deliverycharge) || num(o.deliverycharges),
|
|
paymenttype: num(o.paymenttype),
|
|
ordernotes: str(o.ordernotes),
|
|
pickupcustomer: str(o.pickupcustomer) || str(o.tenantname),
|
|
pickupcontactno: str(o.pickupcontactno),
|
|
pickupaddress: str(o.pickupaddress),
|
|
pickuplocationid: num(o.pickuplocationid),
|
|
pickuplat: str(o.pickuplat),
|
|
pickuplon: str(o.pickuplong) || str(o.pickuplon),
|
|
deliverycustomerid: num(o.deliverycustomerid),
|
|
deliverylocationid: num(o.deliverylocationid),
|
|
deliverycustomer: str(o.deliverycustomer),
|
|
deliverycontactno: str(o.deliverycontactno),
|
|
deliveryaddress: str(o.deliveryaddress),
|
|
deliverylat: str(o.deliverylat),
|
|
deliverylong: str(o.deliverylong),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Assign a rider to one or more orders — the CORRECT flow per the backend:
|
|
* • orders with no delivery yet (`deliveryid == 0`, i.e. freshly created) →
|
|
* POST /deliveries/createdeliveries (one batched call). This creates the
|
|
* delivery, enqueues it, AND flips the order to `pending`.
|
|
* • orders that already have a delivery → PUT /deliveries/updatedelivery to
|
|
* re-point the rider.
|
|
* The rider (userid) MUST belong to the same tenant/partner as the orders, or
|
|
* the backend rejects the assignment — that scoping is enforced on the rider
|
|
* list (see getRiders' partnerid).
|
|
*/
|
|
export async function assignRiderToOrders(
|
|
userid: number,
|
|
orders: Row[],
|
|
): Promise<{ ok: number; failed: number; total: number }> {
|
|
const assigntime = nowStamp();
|
|
const toCreate = orders.filter((o) => !num(o.deliveryid));
|
|
const toUpdate = orders.filter((o) => num(o.deliveryid));
|
|
let ok = 0;
|
|
let failed = 0;
|
|
|
|
if (toCreate.length) {
|
|
try {
|
|
await fiestaSend('deliveries/createdeliveries', 'POST', toCreate.map((o) => deliveryFromOrder(o, userid, assigntime)));
|
|
ok += toCreate.length;
|
|
} catch {
|
|
failed += toCreate.length;
|
|
}
|
|
}
|
|
|
|
if (toUpdate.length) {
|
|
const results = await Promise.allSettled(
|
|
toUpdate.map((o) =>
|
|
fiestaSend('deliveries/updatedelivery', 'PUT', {
|
|
userid,
|
|
deliveryid: num(o.deliveryid),
|
|
orderheaderid: num(o.orderheaderid),
|
|
orderstatus: 'pending',
|
|
assigntime,
|
|
}),
|
|
),
|
|
);
|
|
ok += results.filter((r) => r.status === 'fulfilled').length;
|
|
failed += results.filter((r) => r.status === 'rejected').length;
|
|
}
|
|
|
|
return { ok, failed, total: orders.length };
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// RIDER PUSH NOTIFICATION
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Thrown when the rider has no registered device. Distinct from a transport
|
|
* failure because the remedy is different — the rider must open the app and
|
|
* sign in, not retry. Without this the operator sees a generic "notification
|
|
* failed" and assumes the network is at fault.
|
|
*/
|
|
export class RiderNotReachableError extends Error {
|
|
constructor(message = 'This rider has no device registered, so they were not notified.') {
|
|
super(message);
|
|
this.name = 'RiderNotReachableError';
|
|
}
|
|
}
|
|
|
|
export interface NotifyRiderInput {
|
|
token: string;
|
|
title?: string;
|
|
body: string;
|
|
/** Silent payload the rider app switches on, e.g. `{ type: 'cancel' }`. */
|
|
data?: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* POST /utils/notifyuser — relays an FCM push to a rider through the backend,
|
|
* which holds the Firebase service account.
|
|
*
|
|
* Fire-and-forget by design: there is no delivery receipt and no retry. The
|
|
* delivery row is already committed by the time this runs, so a failure here
|
|
* means the rider has work they have not been told about — which is why it is
|
|
* surfaced to the operator rather than swallowed.
|
|
*/
|
|
export async function notifyRider(input: NotifyRiderInput): Promise<Row> {
|
|
const token = (input.token ?? '').trim();
|
|
// Checked before the request: posting an empty token returns a generic FCM
|
|
// "invalid argument", which reads as a server fault rather than a rider who
|
|
// has never opened the app.
|
|
if (!token) throw new RiderNotReachableError();
|
|
|
|
return fiestaSend<Row>('utils/notifyuser', 'POST', {
|
|
token,
|
|
notification: {
|
|
title: input.title ?? 'NearleXpress',
|
|
body: input.body,
|
|
sound: 'ring',
|
|
image: '',
|
|
},
|
|
...(input.data ? { data: input.data } : {}),
|
|
});
|
|
}
|
|
|
|
/** Standard message bodies, kept together so the wording stays consistent. */
|
|
export const RIDER_MESSAGES = {
|
|
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.`,
|
|
reassigned: 'A delivery has been assigned to you. Kindly accept and process it.',
|
|
reminder: 'You have deliveries waiting. Kindly accept and process them.',
|
|
cancelled: (orderid: string) => `${orderid} has been cancelled.`,
|
|
} as const;
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// PARTNERS / RIDERS
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* /partners/getriders?applocationid=&partnerid=&tenantid= — riders on duty NOW.
|
|
*
|
|
* Despite the name this is a presence query, not a roster. The backend requires
|
|
* status='Active', onduty=1, and a riderlog dated today with logstatus=0, then
|
|
* joins each rider's most recent GPS ping. So it answers "who is working right
|
|
* now", and the rows carry userfcmtoken for notifying them.
|
|
*
|
|
* Scope by applocationid or partnerid. NOT by tenantid: a rider record leaves
|
|
* app_users.tenantid unset (riders belong to a partner and an app-location), so
|
|
* a tenant-scoped call returns an empty list for every tenant. The backend
|
|
* checks applocationid first, then partnerid, then tenantid, so passing an
|
|
* app-location alongside anything else wins.
|
|
*/
|
|
export async function getRiders(opts: {
|
|
applocationid?: number;
|
|
tenantid?: number;
|
|
partnerid?: number;
|
|
}): Promise<Row[]> {
|
|
const scoped = opts.applocationid || opts.partnerid || opts.tenantid;
|
|
return toRows(
|
|
await fiestaGet('partners/getriders', {
|
|
applocationid: scoped ? opts.applocationid : FIESTA_APPLOCATION_ID,
|
|
tenantid: opts.tenantid,
|
|
partnerid: opts.partnerid,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /partners/getridershifts?applocationid= — rider shift records. */
|
|
export async function getRiderShifts(applocationid: number = FIESTA_APPLOCATION_ID): Promise<Row[]> {
|
|
return toRows(await fiestaGet('partners/getridershifts/', { applocationid }));
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// TENANTS / CUSTOMERS
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Throwaway/test email providers. A location whose contact email is on one of
|
|
* these is a sandbox record, never a real outlet — used to drop test data.
|
|
*/
|
|
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
|
'mailinator.com',
|
|
'mailinator.net',
|
|
'example.com',
|
|
'example.org',
|
|
'test.com',
|
|
'yopmail.com',
|
|
'guerrillamail.com',
|
|
'10minutemail.com',
|
|
]);
|
|
|
|
/**
|
|
* The tenant-locations endpoint for some tenants returns junk: the primary
|
|
* outlet duplicated several times, plus orphan test records geocoded to random
|
|
* countries (e.g. "Deborah Lara, Spain", "power, Ireland") with throwaway
|
|
* emails. This strips both so the registry/inventory show only real outlets.
|
|
*
|
|
* The filter is self-calibrating (no hardcoded names/ids): it derives the
|
|
* tenant's operating region from the most common state among its outlets, then
|
|
* drops rows that either use a disposable email or sit outside that region. If
|
|
* the region can't be established (no state data), nothing is region-filtered —
|
|
* we'd rather show an extra row than hide a genuine outlet.
|
|
*/
|
|
export function cleanTenantLocations(rows: Row[]): Row[] {
|
|
// 1. Dedupe by locationid — the API repeats the primary outlet.
|
|
const seen = new Set<number>();
|
|
const deduped = rows.filter((r) => {
|
|
const id = num(r.locationid);
|
|
if (!id || seen.has(id)) return false;
|
|
seen.add(id);
|
|
return true;
|
|
});
|
|
|
|
// 2. Find the tenant's home region (plurality of `state`).
|
|
const stateCounts = new Map<string, number>();
|
|
for (const r of deduped) {
|
|
const st = str(r.state).trim().toLowerCase();
|
|
if (st) stateCounts.set(st, (stateCounts.get(st) ?? 0) + 1);
|
|
}
|
|
let homeState = '';
|
|
let max = 0;
|
|
for (const [st, c] of stateCounts) {
|
|
if (c > max) {
|
|
max = c;
|
|
homeState = st;
|
|
}
|
|
}
|
|
|
|
// 3. Drop disposable-email rows and out-of-region rows.
|
|
return deduped.filter((r) => {
|
|
const emailDomain = (str(r.email).split('@')[1] ?? '').trim().toLowerCase();
|
|
if (emailDomain && DISPOSABLE_EMAIL_DOMAINS.has(emailDomain)) return false;
|
|
if (homeState) {
|
|
const st = str(r.state).trim().toLowerCase();
|
|
if (st && st !== homeState) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/** /tenants/gettenantlocations?tenantid=&userid= — outlet locations for a tenant (test rows stripped). */
|
|
export async function getTenantLocations(tenantid: number, userid?: number): Promise<Row[]> {
|
|
const params: Record<string, any> = { tenantid };
|
|
if (userid) params.userid = userid;
|
|
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', params)));
|
|
}
|
|
|
|
/** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */
|
|
export async function getAllTenants(opts: {
|
|
applocationid?: number;
|
|
status?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
} = {}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('tenants/getalltenants', {
|
|
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
|
status: opts.status ?? 'Active',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 20,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Collapse the gettenantcustomers rows to one per customer. The endpoint returns
|
|
* one row per saved DELIVERY ADDRESS (each carries its own deliverylocationid /
|
|
* address), so a customer with several addresses repeats many times. Key by
|
|
* customerid (fall back to contactno), preferring the row flagged primaryaddress;
|
|
* rows with no identity at all are kept as-is so nothing is silently dropped.
|
|
*/
|
|
export function dedupeCustomers(rows: Row[]): Row[] {
|
|
const byCustomer = new Map<string, Row>();
|
|
for (const r of rows) {
|
|
const cid = num(r.customerid);
|
|
const key = cid ? `c${cid}` : (str(r.contactno) ? `p${str(r.contactno)}` : `x${byCustomer.size}`);
|
|
const existing = byCustomer.get(key);
|
|
if (!existing || (num(r.primaryaddress) && !num(existing.primaryaddress))) {
|
|
byCustomer.set(key, r);
|
|
}
|
|
}
|
|
return [...byCustomer.values()];
|
|
}
|
|
|
|
/** /customers/gettenantcustomers?tenantid=&locationid=&pageno=&pagesize=&keyword= (deduped per customer). */
|
|
export async function getTenantCustomers(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return dedupeCustomers(toRows(
|
|
await fiestaGet('customers/gettenantcustomers', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
keyword: opts.keyword ?? '',
|
|
pageno: opts.pageno ?? 1,
|
|
// When a store is named the backend joins `customerlocations`, so it
|
|
// returns one row per SAVED ADDRESS and applies LIMIT to those rows — not
|
|
// to customers. Live: locationid=1185 → 12 rows → 2 customers (11 of them
|
|
// one person's addresses). The old default of 20 therefore showed a store
|
|
// roughly three customers. Ask for enough rows that dedupe still has every
|
|
// customer to work with; the backend's DISTINCT ON fix makes this generous
|
|
// rather than load-bearing.
|
|
pagesize: opts.pagesize ?? 500,
|
|
}),
|
|
));
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// PRODUCTS / STOCK
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** /products/getstockstatement?tenantid=&locationid=&subcategoryid=&keyword=&pageno=&pagesize= */
|
|
export async function getStockStatement(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
subcategoryid?: number;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getstockstatement', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
subcategoryid: opts.subcategoryid,
|
|
keyword: opts.keyword ?? '',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 50,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /products/getproductscount?tenantid=&categoryid=&subcategoryid=&approve= */
|
|
export async function getProductsCount(opts: {
|
|
tenantid: number;
|
|
categoryid: number;
|
|
subcategoryid?: number;
|
|
}): Promise<Row | null> {
|
|
return firstRow(
|
|
await fiestaGet('products/getproductscount', {
|
|
tenantid: opts.tenantid,
|
|
categoryid: opts.categoryid,
|
|
subcategoryid: opts.subcategoryid,
|
|
approve: 1,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /products/getproductstocks?tenantid=&locationid= — live stock levels for an outlet. */
|
|
export async function getProductStocks(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getproductstocks', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /products/getlocationproducts?tenantid=&locationid=&subcategoryid=&pageno=&pagesize= —
|
|
* geofenced per-outlet inventory. */
|
|
export async function getProductLocations(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
subcategoryid?: number;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getlocationproducts', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
subcategoryid: opts.subcategoryid,
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 50,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /products/getproducts?tenantid=&locationid=&subcategoryid=&keyword=&pageno=&pagesize= —
|
|
* master catalog listings (global assortment). */
|
|
export async function getMasterCatalog(opts: {
|
|
tenantid: number;
|
|
locationid?: number;
|
|
subcategoryid?: number;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getproducts', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
subcategoryid: opts.subcategoryid,
|
|
keyword: opts.keyword ?? '',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 50,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export interface CreateProductLocationInput {
|
|
tenantid: number;
|
|
locationid: number;
|
|
productid: number;
|
|
quantity?: number; // User prompt specified quantity
|
|
qty?: number; // Keep for backwards compatibility if needed
|
|
stocktype?: string;
|
|
status?: string;
|
|
price?: number;
|
|
}
|
|
|
|
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */
|
|
export async function createProductLocation(input: CreateProductLocationInput): Promise<Row> {
|
|
// The selling price field is `price` — it binds to models.Productlocations
|
|
// .Price, and CreateProductLocation's upsert lists "price" in its
|
|
// DoUpdates, so this both inserts and updates the per-store price.
|
|
// (`retailprice` is the MASTER price on the products table; that struct
|
|
// ignores it, so sending it here persisted nothing.) Only send when a price
|
|
// was supplied, so a quantity-only write can't blank an existing one.
|
|
const payload: Record<string, unknown> = {
|
|
tenantid: input.tenantid,
|
|
locationid: input.locationid,
|
|
productid: input.productid,
|
|
quantity: input.quantity ?? input.qty ?? 0,
|
|
stocktype: input.stocktype || 'in',
|
|
status: input.status || 'Active',
|
|
};
|
|
if (num(input.price) > 0) payload.price = input.price;
|
|
return fiestaSend<Row>('products/createproductlocation', 'POST', [payload]);
|
|
}
|
|
|
|
export interface DeleteProductLocationInput {
|
|
tenantid: number;
|
|
locationid: number;
|
|
productid: number;
|
|
}
|
|
|
|
/** DELETE /products/deleteproductlocation — Remove a product from a store catalogue. */
|
|
export async function deleteProductLocation(input: DeleteProductLocationInput): Promise<Row> {
|
|
return fiestaSend<Row>('products/deleteproductlocation', 'DELETE', input);
|
|
}
|
|
|
|
export interface StockRequestInput {
|
|
tenantid: number;
|
|
locationid: number;
|
|
productid: number;
|
|
qty: number;
|
|
status?: string;
|
|
locationname?: string;
|
|
productname?: string;
|
|
productimage?: string;
|
|
tenantname?: string;
|
|
}
|
|
|
|
/** POST /products/createstockrequest — Store user requests stock. */
|
|
export async function createStockRequest(input: StockRequestInput): Promise<Row> {
|
|
return fiestaSend<Row>('products/createstockrequest', 'POST', input);
|
|
}
|
|
|
|
/** /products/getstockrequests?tenantid=&locationid=&status=&pageno=&pagesize= —
|
|
* Fetch pending/approved stock requests. */
|
|
export async function getStockRequests(opts: {
|
|
tenantid: number;
|
|
locationid?: number;
|
|
status?: string;
|
|
date?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getstockrequests', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
status: opts.status ?? '',
|
|
date: opts.date,
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 50,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export interface UpdateStockRequestInput {
|
|
requestid?: number;
|
|
tenantid?: number;
|
|
locationid?: number;
|
|
productid?: number;
|
|
status: string;
|
|
}
|
|
|
|
/** PUT /products/updatestockrequest — Admin approves or rejects a stock request. */
|
|
export async function updateStockRequest(input: UpdateStockRequestInput): Promise<Row> {
|
|
return fiestaSend<Row>('products/updatestockrequest', 'PUT', input);
|
|
}
|
|
|
|
/** /products/getproductcategories — global product categories. */
|
|
export async function getProductCategories(): Promise<Row[]> {
|
|
return toRows(await fiestaGet('products/getproductcategories', {}));
|
|
}
|
|
|
|
/** /products/getproductsubcategories?categoryid=&tenantid= — subcategories under a category. */
|
|
export async function getProductSubcategories(opts: {
|
|
categoryid: number;
|
|
tenantid?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getproductsubcategories', {
|
|
categoryid: opts.categoryid,
|
|
tenantid: opts.tenantid,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// USERS
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Best-effort role label from the numeric roleid (roles aren't fully resolvable for every config). */
|
|
export function roleName(roleid: number): string {
|
|
const map: Record<number, string> = {
|
|
[-1]: 'Unassigned',
|
|
0: 'Unassigned',
|
|
1: 'Owner',
|
|
2: 'Manager',
|
|
3: 'Admin',
|
|
4: 'Staff',
|
|
5: 'Rider',
|
|
6: 'Cashier',
|
|
};
|
|
return map[roleid] || `Role ${roleid}`;
|
|
}
|
|
|
|
/** /users/getallusers?roleid=&tenantid=&pageno=&pagesize=&keyword= — staff/users under a tenant. */
|
|
export async function getAllUsers(opts: {
|
|
tenantid: number;
|
|
roleid?: number;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('users/getallusers', {
|
|
tenantid: opts.tenantid,
|
|
roleid: opts.roleid,
|
|
keyword: opts.keyword ?? '',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 50,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /users/getusers?userid= — a single user profile. */
|
|
export async function getUserById(userid: number): Promise<Row | null> {
|
|
return firstRow(await fiestaGet('users/getusers', { userid }));
|
|
}
|
|
|
|
export interface CreateUserInput {
|
|
firstname: string;
|
|
lastname?: string;
|
|
email: string;
|
|
contactno: string;
|
|
/** Optional — merchant_web's create form doesn't collect one. */
|
|
password?: string;
|
|
roleid: number;
|
|
/**
|
|
* app_users.configid — the same column every login call filters on
|
|
* (auth.ts hardcodes configid: 1). Must stay 1 or the account can never
|
|
* be found on login; this is NOT the unrelated Hasura "role config" id.
|
|
*/
|
|
configid?: number;
|
|
/** Business module id (merchant_web sends the logged-in user's; 0 when absent). */
|
|
moduleid?: number;
|
|
dialcode?: string;
|
|
pin?: number;
|
|
address?: string;
|
|
suburb?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postcode?: string;
|
|
latitude?: string;
|
|
longitude?: string;
|
|
/** Rider shift (only meaningful for rider-role users). */
|
|
shiftid?: number;
|
|
tenantid: number;
|
|
locationid?: number;
|
|
applocationid?: number;
|
|
status?: string;
|
|
}
|
|
|
|
/** POST /users/create — register a new web staff user. */
|
|
export async function createUser(input: CreateUserInput): Promise<Row> {
|
|
return fiestaSend<Row>('users/create', 'POST', {
|
|
authname: input.email,
|
|
firstname: input.firstname,
|
|
lastname: input.lastname ?? '',
|
|
password: input.password ?? '',
|
|
email: input.email,
|
|
dialcode: input.dialcode ?? '+91',
|
|
contactno: input.contactno,
|
|
roleid: input.roleid,
|
|
configid: input.configid ?? 1,
|
|
moduleid: input.moduleid ?? 0,
|
|
pin: input.pin ?? 0,
|
|
address: input.address ?? '',
|
|
suburb: input.suburb ?? '',
|
|
city: input.city ?? '',
|
|
state: input.state ?? '',
|
|
postcode: input.postcode ?? '',
|
|
latitude: input.latitude ?? '',
|
|
longitude: input.longitude ?? '',
|
|
shiftid: input.shiftid ?? 0,
|
|
tenantid: input.tenantid,
|
|
locationid: input.locationid ?? 0,
|
|
applocationid: input.applocationid ?? FIESTA_APPLOCATION_ID,
|
|
status: input.status ?? 'active',
|
|
});
|
|
}
|
|
|
|
export interface UpdateUserInput {
|
|
userid: number;
|
|
firstname?: string;
|
|
lastname?: string;
|
|
email?: string;
|
|
contactno?: string;
|
|
address?: string;
|
|
suburb?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postcode?: string;
|
|
status?: string;
|
|
roleid?: number | null;
|
|
locationid?: number | null;
|
|
applocationid?: number | null;
|
|
}
|
|
|
|
/** PUT /users/update — update an existing web staff user. */
|
|
export async function updateUser(input: UpdateUserInput): Promise<Row> {
|
|
return fiestaSend<Row>('users/update', 'PUT', input);
|
|
}
|
|
|
|
/**
|
|
* PUT /users/update — set (or reset) a user's login password. Same endpoint as
|
|
* `updateUser`, called with only userid + password so every other column is
|
|
* left untouched (the backend's Updates() skips zero-value fields).
|
|
*/
|
|
export async function setUserPassword(userid: number, password: string): Promise<Row> {
|
|
return fiestaSend<Row>('users/update', 'PUT', { userid, password });
|
|
}
|
|
|
|
export interface CreateTenantLocationPayload {
|
|
locationname: string;
|
|
email?: string;
|
|
contactno?: string;
|
|
address?: string;
|
|
suburb?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postcode?: string;
|
|
latitude?: string;
|
|
longitude?: string;
|
|
applocationid?: number;
|
|
}
|
|
|
|
export interface CreateTenantInput {
|
|
tenantname: string;
|
|
companyname: string;
|
|
primarycontact: string;
|
|
primaryemail: string;
|
|
address?: string;
|
|
suburb?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postcode?: string;
|
|
latitude?: string;
|
|
longitude?: string;
|
|
approved?: number;
|
|
status?: string;
|
|
applocationid?: number;
|
|
categoryid?: number;
|
|
/** Auto-provisions the tenant's primary (Active) location in the same call. */
|
|
tenantlocations?: CreateTenantLocationPayload;
|
|
}
|
|
|
|
/** POST /tenants/createtenantuser — Onboard a new tenant, primary location, and admin user. */
|
|
export async function createTenantUser(input: CreateTenantInput): Promise<Row> {
|
|
const res = await fetch(`${FIESTA_BASE}/tenants/createtenantuser`, {
|
|
method: 'POST',
|
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(input),
|
|
});
|
|
const json = (await res.json().catch(() => null)) as { message?: string; status?: boolean } | null;
|
|
if (!res.ok || (json && json.status === false)) {
|
|
throw new Error(json?.message || `Tenant onboarding failed: ${res.status}`);
|
|
}
|
|
return json as Row;
|
|
}
|
|
|
|
export interface CreateTenantLocationInput {
|
|
tenantid: number;
|
|
locationname: string;
|
|
address?: string;
|
|
suburb?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postcode?: string;
|
|
contactno?: string;
|
|
email?: string;
|
|
opentime?: string;
|
|
closetime?: string;
|
|
deliverymins?: number;
|
|
deliveryradius?: number;
|
|
latitude?: string;
|
|
longitude?: string;
|
|
status?: string;
|
|
moduleid?: number;
|
|
applocationid?: number;
|
|
partnerid?: number;
|
|
roleid?: number;
|
|
}
|
|
|
|
/**
|
|
* POST /tenants/createtenantlocation — Create a new store location under a tenant.
|
|
* The envelope's `details` is the created location row (including the
|
|
* DB-assigned `locationid`), needed right after onboarding to build the
|
|
* store's QR code via `buildStoreQrPayload`.
|
|
*/
|
|
export async function createTenantLocation(input: CreateTenantLocationInput): Promise<Row> {
|
|
return fiestaSend<Row>('tenants/createtenantlocation', 'POST', input);
|
|
}
|
|
|
|
export interface UpdateTenantLocationInput {
|
|
locationid: number;
|
|
tenantid?: number;
|
|
locationname?: string;
|
|
contactno?: string;
|
|
email?: string;
|
|
status?: string;
|
|
suburb?: string;
|
|
city?: string;
|
|
}
|
|
|
|
/** PUT /tenants/updatetenantlocation — Update store location details/status. */
|
|
export async function updateTenantLocation(input: UpdateTenantLocationInput): Promise<Row> {
|
|
return fiestaSend<Row>('tenants/updatetenantlocation', 'PUT', input);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// RIDERS / DISPATCH
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** /riders/getriderperiodiclogs?userid=&riderid=&fromdate=&todate=&tenantid=&applocationid= —
|
|
* periodic GPS/status snapshots for a rider across a date range. */
|
|
export async function getRiderPeriodicLogs(opts: {
|
|
userid?: number;
|
|
riderid?: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
tenantid?: number;
|
|
applocationid?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('riders/getriderperiodiclogs', {
|
|
userid: opts.userid,
|
|
riderid: opts.riderid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
tenantid: opts.tenantid,
|
|
applocationid: opts.applocationid,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /partners/getriderlogs?userid=&riderid=&fromdate=&todate=&tenantid=&applocationid= —
|
|
* full telemetry logs (GPS traces, events, etc.) for a rider. */
|
|
export async function getRiderLogs(opts: {
|
|
userid?: number;
|
|
riderid?: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
tenantid?: number;
|
|
applocationid?: number;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('partners/getriderlogs', {
|
|
userid: opts.userid,
|
|
riderid: opts.riderid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
tenantid: opts.tenantid,
|
|
applocationid: opts.applocationid,
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 200,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /partners/getbatchefficiency?partnerid=&tenantid=&fromdate=&todate= —
|
|
* batch/trip efficiency metrics. */
|
|
export async function getBatchEfficiency(opts: {
|
|
partnerid?: number;
|
|
tenantid: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('partners/getbatchefficiency', {
|
|
partnerid: opts.partnerid,
|
|
tenantid: opts.tenantid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** PUT /deliveries/updatedelivery — Manually assign/update a delivery's rider or status. */
|
|
export async function updateDelivery(deliveryid: number, updates: Row): Promise<Row> {
|
|
return fiestaSend<Row>('deliveries/updatedelivery', 'PUT', {
|
|
deliveryid,
|
|
...updates,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Move one delivery to a different rider.
|
|
*
|
|
* Goes through PUT /deliveries/updatedelivery. There is no batch reassign
|
|
* endpoint: /riders/reassigndeliveries, which this used to POST to, is not
|
|
* registered on the backend and answers 404 — it had no callers, so the failure
|
|
* was never observed.
|
|
*
|
|
* Re-assigning resets orderstatus to 'pending', discarding any accepted/arrived
|
|
* progress the previous rider had made. That is the backend's existing
|
|
* behaviour and callers should gate the action accordingly.
|
|
*/
|
|
export async function changeDeliveryRider(opts: {
|
|
deliveryid: number;
|
|
orderheaderid: number;
|
|
userid: number;
|
|
}): Promise<Row> {
|
|
return fiestaSend<Row>('deliveries/updatedelivery', 'PUT', {
|
|
deliveryid: opts.deliveryid,
|
|
orderheaderid: opts.orderheaderid,
|
|
userid: opts.userid,
|
|
orderstatus: 'pending',
|
|
assigntime: nowStamp(),
|
|
});
|
|
}
|
|
|
|
/** Reassign several deliveries to one rider, one call each. Tolerates partial
|
|
* failure and reports it, the same contract as assignRiderToOrders. */
|
|
export async function reassignDeliveries(opts: {
|
|
userid: number;
|
|
deliveries: { deliveryid: number; orderheaderid: number }[];
|
|
}): Promise<{ ok: number; failed: number; total: number }> {
|
|
const results = await Promise.allSettled(
|
|
opts.deliveries.map((d) =>
|
|
changeDeliveryRider({ deliveryid: d.deliveryid, orderheaderid: d.orderheaderid, userid: opts.userid }),
|
|
),
|
|
);
|
|
return {
|
|
ok: results.filter((r) => r.status === 'fulfilled').length,
|
|
failed: results.filter((r) => r.status === 'rejected').length,
|
|
total: opts.deliveries.length,
|
|
};
|
|
}
|
|
|
|
/** POST /v1/web/tenants/createlocation — Create a new tenant location (outlet). */
|
|
export async function createLocation(opts: {
|
|
tenantid: number;
|
|
locationname: string;
|
|
suburb: string;
|
|
contactno?: string;
|
|
status?: string;
|
|
}): Promise<Row> {
|
|
// Use fiestaSend, note that we include /v1/web explicitly here as the FIESTA_BASE is likely just the root.
|
|
// Wait, let's look at FIESTA_BASE in the file.
|
|
// Actually, I'll just use the full endpoint path.
|
|
return fiestaSend<Row>('v1/web/tenants/createlocation', 'POST', opts);
|
|
}
|
|
|
|
/** PUT /v1/web/tenants/updatelocation — Update an existing tenant location (outlet). */
|
|
export async function updateLocation(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
locationname?: string;
|
|
suburb?: string;
|
|
contactno?: string;
|
|
status?: string;
|
|
}): Promise<Row> {
|
|
return fiestaSend<Row>('v1/web/tenants/updatelocation', 'PUT', opts);
|
|
}
|
|
|
|
/** DELETE /v1/web/tenants/deletelocation — Delete an existing tenant location (outlet). */
|
|
export async function deleteLocation(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
}) {
|
|
const qs = new URLSearchParams({
|
|
tenantid: String(opts.tenantid),
|
|
locationid: String(opts.locationid),
|
|
}).toString();
|
|
return await fiestaSend(`tenants/deletelocation?${qs}`, 'POST');
|
|
}
|
|
|
|
export interface SalesSummaryChartData {
|
|
date: string;
|
|
revenue: number;
|
|
orders: number;
|
|
}
|
|
|
|
export interface SalesSummaryTopLocation {
|
|
locationname: string;
|
|
revenue: number;
|
|
}
|
|
|
|
export interface SalesSummaryResponse {
|
|
totalRevenue: number;
|
|
totalOrders: number;
|
|
averageOrderValue: number;
|
|
chartData: SalesSummaryChartData[];
|
|
topLocations: SalesSummaryTopLocation[];
|
|
}
|
|
|
|
/** GET /v1/web/reports/sales-summary — Fetches aggregated sales and revenue data. */
|
|
export async function getSalesSummary(opts: {
|
|
tenantid: number;
|
|
locationid?: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
}): Promise<SalesSummaryResponse> {
|
|
// Use fiestaGet wrapper which parses standard responses
|
|
const res = await fiestaGet<{ details: SalesSummaryResponse }>('v1/web/reports/sales-summary', opts);
|
|
return res.details;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// OFFLINE (IN-STORE) SALES
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export interface SaleTemplateRow {
|
|
tenantid: number;
|
|
locationid: number;
|
|
locationname: string;
|
|
productid: number;
|
|
productname: string;
|
|
productunit: string;
|
|
unitvalue: string;
|
|
categoryname: string;
|
|
currentstock: number;
|
|
price: number;
|
|
taxpercent: number;
|
|
}
|
|
|
|
export interface SaleTemplateLocation {
|
|
locationid: number;
|
|
locationname: string;
|
|
productcount: number;
|
|
}
|
|
|
|
export interface SaleTemplate {
|
|
tenantid: number;
|
|
/** 0 when the template spans every branch of the tenant. */
|
|
locationid: number;
|
|
locations: SaleTemplateLocation[];
|
|
products: SaleTemplateRow[];
|
|
}
|
|
|
|
/**
|
|
* GET /products/getsaletemplate — products stocked across the tenant's
|
|
* branches, each with its live ledger balance and price.
|
|
*
|
|
* `locationid` is optional and defaults to every branch, which is the normal
|
|
* case: one workbook covers the whole business and each row carries the branch
|
|
* its stock belongs to. Pass a locationid to narrow it to a single store.
|
|
*
|
|
* This is what the offline-sales spreadsheet is built from, and the reason it
|
|
* has to be generated rather than hand-written: `productid` is the only usable
|
|
* key for a product. Across the live catalogue 6,245 products share just 93
|
|
* distinct `productsku` values (one tenant has 463 products all carrying sku
|
|
* "1"), so a store cannot identify a product by SKU, and product names are not
|
|
* unique enough either. Pre-filling productid and locationid removes both
|
|
* problems at once.
|
|
*/
|
|
export async function getSaleTemplate(opts: {
|
|
tenantid: number;
|
|
locationid?: number;
|
|
}): Promise<SaleTemplate> {
|
|
const res = await fiestaGet<{ details: SaleTemplate | null }>('products/getsaletemplate', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid ?? 0,
|
|
});
|
|
if (!res?.details) throw new Error('No products are stocked at any of your outlets yet.');
|
|
return res.details;
|
|
}
|
|
|
|
export interface OfflineSaleItemInput {
|
|
productid: number;
|
|
productname?: string;
|
|
qtysold: number;
|
|
unitprice?: number;
|
|
discountamount?: number;
|
|
taxpercent?: number;
|
|
}
|
|
|
|
export interface OfflineSaleBillInput {
|
|
/** Branch this bill was rung up at, taken from the spreadsheet row. */
|
|
locationid: number;
|
|
billno?: string;
|
|
saledate?: string;
|
|
paymentmode?: string;
|
|
customername?: string;
|
|
customermobile?: string;
|
|
remarks?: string;
|
|
items: OfflineSaleItemInput[];
|
|
}
|
|
|
|
export interface OfflineSaleResult {
|
|
locationid: number;
|
|
locationname: string;
|
|
billno: string;
|
|
status: 'imported' | 'duplicate' | 'failed';
|
|
orderid: string;
|
|
orderheaderid: number;
|
|
itemcount: number;
|
|
amount: number;
|
|
message: string;
|
|
}
|
|
|
|
export interface OfflineSalesUploadResponse {
|
|
imported: number;
|
|
duplicate: number;
|
|
failed: number;
|
|
totalamount: number;
|
|
results: OfflineSaleResult[];
|
|
}
|
|
|
|
/**
|
|
* POST /orders/uploadofflinesales — import counter sales as real orders.
|
|
*
|
|
* Each bill is committed independently, so the response reports a per-bill
|
|
* outcome and a partially-good spreadsheet still imports its good bills. A
|
|
* thrown error therefore means nothing at all was attempted (bad outlet, empty
|
|
* batch); individual rejections come back inside `results`.
|
|
*
|
|
* Re-uploading the same file is safe: the backend records each bill number and
|
|
* refuses one it has already imported rather than deducting the stock twice.
|
|
*
|
|
* `locationid` is a scope constraint, not the destination. Omit it and each
|
|
* bill goes to the branch named on its own rows — the multi-branch case. Set it
|
|
* and the upload is pinned to that branch, with any bill naming another one
|
|
* refused; that is how a store user is held to their own store regardless of
|
|
* what the spreadsheet was edited to say.
|
|
*/
|
|
export async function uploadOfflineSales(input: {
|
|
tenantid: number;
|
|
locationid?: number;
|
|
userid?: number;
|
|
bills: OfflineSaleBillInput[];
|
|
}): Promise<OfflineSalesUploadResponse> {
|
|
const res = await fiestaSend<{ details: OfflineSalesUploadResponse }>(
|
|
'orders/uploadofflinesales',
|
|
'POST',
|
|
{
|
|
tenantid: input.tenantid,
|
|
locationid: input.locationid ?? 0,
|
|
userid: input.userid ?? 0,
|
|
bills: input.bills,
|
|
},
|
|
);
|
|
return res.details;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// GLOBAL CATALOGUE
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** GET /v1/web/catalogue/getbrands — List all brands + product count per brand */
|
|
export async function getGlobalBrands(): Promise<Row[]> {
|
|
return toRows(await fiestaGet('catalogue/getbrands'));
|
|
}
|
|
|
|
/** GET /v1/web/catalogue/getcategories — List categories available for that brand */
|
|
export async function getGlobalCategories(opts: { brand: string }): Promise<string[]> {
|
|
const res = await fiestaGet<{ details: string[] }>('catalogue/getcategories', { brand: opts.brand });
|
|
return Array.isArray(res.details) ? res.details : [];
|
|
}
|
|
|
|
/** GET /v1/web/catalogue/getproducts — Product list */
|
|
export async function getGlobalProducts(opts: {
|
|
brand: string;
|
|
category?: string;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(await fiestaGet('catalogue/getproducts', {
|
|
brand: opts.brand,
|
|
category: opts.category,
|
|
keyword: opts.keyword,
|
|
pageno: opts.pageno,
|
|
pagesize: opts.pagesize,
|
|
}));
|
|
}
|
|
|
|
/** GET /v1/web/catalogue/getproduct — Single product lookup by SKU */
|
|
export async function getGlobalProduct(opts: { brand: string; sku: string }): Promise<Row | null> {
|
|
return firstRow(await fiestaGet('catalogue/getproduct', { brand: opts.brand, sku: opts.sku }));
|
|
}
|