initial commit
This commit is contained in:
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 : [])),
|
||||
};
|
||||
Reference in New Issue
Block a user