/** * 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(`${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(`${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(`${WEB}/orders/getordersummary`, { tenantid, ...range }), /** Per-branch order totals for one tenant. `tenantid` is required. */ locationSummary: (tenantid: number, range: DateRange = {}) => api.list(`${WEB}/orders/getlocationsummary`, { tenantid, ...range }), revenueSummary: (tenantid: number, range: DateRange = {}) => api.get(`${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[]>(`${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(`${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(`${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(`${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(`${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(`${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 => api .get(`${POS}/health/location`, { location_id: locationid }) .then((health) => (Array.isArray(health?.terminals) ? health.terminals : [])), };