import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types'; /** * Turning an order into a delivery job. * * Assigning a rider is not a status change — it is an INSERT. `createdeliveries` * writes a row in `deliveries`, copies it into `deliveryqueues` for the rider's * app, and moves `orders.orderstatus`, all in one transaction. Until it runs, * a delivery order is not on the deliveries page at all, because deliveries are * their own table and not a filter over orders. * * That makes the payload load-bearing in a way a status write never is: every * read that comes afterwards joins on the ids written here, and a join that * misses drops the row silently. Everything in this file exists to stop a * delivery being created that nobody can then see. * * Pure, so the rules below can be tested without a rider, an order or a * network. */ /** * Has this order been turned into a delivery yet? * * Two signals, because one of them is younger than the data. * * - `deliveryid` on the order row. The direct answer, and it was unavailable * until this work: `orders.deliveryid` was never written, and the orders * query aliased the column away so the field came back 0 on every row ever * returned. Both are fixed, but only for deliveries created from now on. * - `assigned`, the set of `orderheaderid`s the deliveries list already holds. * This is what covers the rows that predate the fix — production's existing * deliveries all sit against an order whose `deliveryid` is still null. * * `rider` reads like a third signal and is not: it comes from a join on the * rider's `app_users` row and is empty on plenty of orders that DO have a * delivery, so testing it would offer to assign jobs already out with somebody. */ export function isUnassigned(row: OrderRow, assigned?: ReadonlySet): boolean { if (row.deliveryid) return false; return !assigned?.has(row.orderheaderid); } /** * The orders the deliveries list says are spoken for. * * Built from whatever `getdeliveries` returned for the same window the orders * came from, which is the pairing the Sales page already has in hand. */ export function assignedFrom(deliveries: readonly { orderheaderid?: number }[]): Set { const out = new Set(); for (const row of deliveries) if (row.orderheaderid) out.add(row.orderheaderid); return out; } /** * Does this order need a rider at all? * * NOT `deliverytype`. That field is empty on every order in production — all * 379 of Suriya Store's — so filtering on it hides the entire list. The drop * address is the honest test: an order with somewhere to be delivered to is a * delivery, and a counter sale has neither address nor customer. */ export function needsDelivery(row: OrderRow): boolean { return Boolean((row.deliveryaddress ?? '').trim() || (row.deliverycustomer ?? '').trim()); } /** * Orders waiting for a rider, oldest first. * * Oldest first, against the newest-first convention everywhere else on the * page, because this is a work queue rather than a log: the order that has * waited longest is the one to deal with next. */ export function awaitingRider( rows: readonly OrderRow[], assigned?: ReadonlySet, ): OrderRow[] { return rows .filter((row) => isUnassigned(row, assigned) && needsDelivery(row) && !isFinished(row)) .sort((a, b) => placedAt(a) - placedAt(b)); } /** An order that is already done or called off wants no rider. */ function isFinished(row: OrderRow): boolean { const status = (row.orderstatus ?? '').trim().toLowerCase(); return status.includes('deliver') || status.includes('cancel') || status.includes('reject'); } /** When the order was placed, as an instant. 0 when the stamp is unreadable. */ export function placedAt(row: OrderRow): number { const at = new Date(row.orderdate ?? '').getTime(); return Number.isNaN(at) ? 0 : at; } /** How long an order has been waiting for a rider. `null` when undatable. */ export function waitingMs(row: OrderRow, now = Date.now()): number | null { const at = placedAt(row); if (at === 0) return null; return Math.max(0, now - at); } /* ── Whether it can be assigned at all ───────────────────────────────────── */ export interface Assignability { canAssign: boolean; /** Why not, in words for an operator. Empty when it can. */ reason: string; } /** * Can a delivery for this order actually be created AND then seen? * * The second half is the part worth guarding. `createdeliveries` will happily * insert a row with a zero `applocationid` and report success — but three of * the reads that follow join `app_location` and `app_locationconfig` on it, so * the job would exist, occupy the rider's queue, and never appear on any * screen. Verified: Suriya Store NSN (location 1172) carries * `applocationid: 0` today, so this is not hypothetical. * * Refusing up front is the honest answer. The alternative is a button that * reports success and loses the order, which is the worst outcome available * and exactly what "it worked on my test tenant" produces. */ export function assignability( row: OrderRow, branch: TenantLocation | undefined, assigned?: ReadonlySet, ): Assignability { const applocationid = row.applocationid || branch?.applocationid || 0; if (!row.orderheaderid) { return { canAssign: false, reason: 'This order has no id to attach a delivery to.' }; } if (!applocationid) { return { canAssign: false, reason: 'This branch has no delivery region set, so a job created for it would not appear anywhere. Set the region on the branch first.', }; } if (!row.locationid) { return { canAssign: false, reason: 'This order is not attached to a branch.' }; } if (!needsDelivery(row)) { return { canAssign: false, reason: 'This order has no delivery address.' }; } if (!isUnassigned(row, assigned)) { return { canAssign: false, reason: 'A rider is already on this one.' }; } return { canAssign: true, reason: '' }; } /* ── The payload ─────────────────────────────────────────────────────────── */ /** * The status a new delivery is created with. * * `pending`, lowercase, and it matters more than it looks. The rider's app * reads its jobs from `getdeliveryqueues`, which filters * `WHERE a.orderstatus = 'pending' AND a.userid = ?`. Any other word — and * "Assigned" is the obvious one to reach for, because the order side uses * capitalised words — creates a job the assigned rider can never see. * * The delivery ladder is its own vocabulary and all of it is lowercase: * pending → accepted → arrived → picked → active → delivered, with cancelled * and skipped off to the side. */ export const DELIVERY_CREATED_STATUS = 'pending'; /** Fiesta wants `YYYY-MM-DD HH:MM:SS` in local time, not an ISO instant. */ export function stampNow(now = new Date()): string { const pad = (n: number) => String(n).padStart(2, '0'); return ( `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` + `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` ); } /** * One row of the `createdeliveries` array. * * `Pickupaddress` is capitalised because that is the JSON tag on * `models.Deliveries`, alone among fifty lowercase ones. Matching it exactly is * a courtesy rather than a requirement: Go's decoder matches field names * case-insensitively, so the old console's lowercase `pickupaddress` landed * fine — verified. Spelled as the model spells it so a reader grepping the Go * struct finds this. */ export interface DeliveryDraft { orderheaderid: number; orderid: string; tenantid: number; locationid: number; applocationid: number; partnerid: number; configid: number; moduleid: number; categoryid: number; subcategoryid: number; userid: number; orderstatus: string; deliverydate: string; assigntime: string; itemcount: number; orderamount: number; deliveryamt: number; deliverycharges: number; paymenttype: number; customerid: number; deliverycustomerid: number; pickupcustomer: string; pickupcontactno: string; pickuplocationid: number; Pickupaddress: string; pickuplat: string; pickuplon: string; deliverycustomer: string; deliverycontactno: string; deliverylocationid: number; deliveryaddress: string; deliverylat: string; deliverylong: string; ordernotes: string; kms: string; } /** * Build the delivery row for one order and one rider. * * Every id is carried across from the order rather than defaulted, because the * delivery inherits the order's place in the world — the same tenant, branch, * region, partner and category. The old console's rows show what happens when * they are not: production deliveries sit there with `itemcount: 0` and * `orderamount: 0` against orders worth ₹80, so the deliveries page cannot * show a rider what they are carrying. Those are copied here. * * `branch` supplies the pickup end and the region when the order row is thin * about them; the order always wins where it has a value. */ export function buildDelivery( row: OrderRow, rider: RiderInfo, branch: TenantLocation | undefined, now = new Date(), ): DeliveryDraft { const at = stampNow(now); return { orderheaderid: row.orderheaderid, orderid: row.orderid ?? '', tenantid: row.tenantid ?? branch?.tenantid ?? 0, locationid: row.locationid ?? branch?.locationid ?? 0, applocationid: row.applocationid || branch?.applocationid || 0, partnerid: row.partnerid ?? 0, configid: row.configid ?? 0, moduleid: row.moduleid ?? 0, categoryid: row.categoryid ?? 0, subcategoryid: row.subcategoryid ?? 0, userid: rider.userid, orderstatus: DELIVERY_CREATED_STATUS, /* * The date the job is FOR, not today. * * An order row carries its scheduled slot as `deliverytime` — there is no * `deliverydate` on an order — so it is copied through, with the order date * and finally the assign stamp behind it. The fallback chain is the old * console's and the reason is its reason: `getdeliveries` filters on * `deliverydate`, so a delivery written without one is excluded from every * dated view, and a job stamped today when it is scheduled for tomorrow * shows up in the wrong day's window. */ deliverydate: row.deliverydate || row.deliverytime || row.orderdate || at, assigntime: at, // What the rider is carrying, so the deliveries page can say so. itemcount: row.itemcount ?? row.quantity ?? 0, orderamount: row.orderamount ?? row.ordervalue ?? 0, deliveryamt: row.ordervalue ?? row.orderamount ?? 0, deliverycharges: row.deliverycharge ?? 0, paymenttype: row.paymenttype ?? 0, customerid: row.customerid ?? 0, deliverycustomerid: row.deliverycustomerid ?? 0, // Pickup: the shop. The order carries it, and the branch is the fallback // for the orders that do not. pickupcustomer: row.pickupcustomer || branch?.locationname || '', pickupcontactno: row.pickupcontactno || branch?.contactno || '', pickuplocationid: row.pickuplocationid || row.locationid || 0, Pickupaddress: row.pickupaddress || branch?.address || '', pickuplat: row.pickuplat || branch?.latitude || '', pickuplon: row.pickuplong || branch?.longitude || '', // Drop: the shopper. No fallback — an order without these was refused by // `assignability` before it reached here. deliverycustomer: row.deliverycustomer ?? '', deliverycontactno: row.deliverycontactno ?? '', deliverylocationid: row.deliverylocationid ?? 0, deliveryaddress: row.deliveryaddress ?? '', deliverylat: row.deliverylat ?? '', deliverylong: row.deliverylong ?? '', ordernotes: row.ordernotes ?? '', kms: row.kms ?? '', }; } /** * The whole batch, for orders that can take one. * * `createdeliveries` accepts an array and writes each row in the same * transaction, so assigning eight orders to one rider is one call. Orders that * fail `assignability` are dropped rather than sent — the endpoint reports one * outcome for the whole array, so a row that cannot work takes the good ones * down with it. */ export function buildDeliveries( rows: readonly OrderRow[], rider: RiderInfo, branchOf: (row: OrderRow) => TenantLocation | undefined, now = new Date(), assigned?: ReadonlySet, ): { drafts: DeliveryDraft[]; skipped: { row: OrderRow; reason: string }[] } { const drafts: DeliveryDraft[] = []; const skipped: { row: OrderRow; reason: string }[] = []; for (const row of rows) { const branch = branchOf(row); const verdict = assignability(row, branch, assigned); if (verdict.canAssign) { drafts.push(buildDelivery(row, rider, branch, now)); } else { skipped.push({ row, reason: verdict.reason }); } } return { drafts, skipped }; } /** How a rider reads in the picker. Falls back through the name fields. */ export function riderName(rider: RiderInfo): string { const full = (rider.fullname ?? '').trim(); if (full) return full; const joined = `${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim(); return joined || `Rider ${rider.userid}`; } /** The vehicle line under the name. Empty when nothing is recorded. */ export function riderVehicle(rider: RiderInfo): string { return [rider.vehiclename, rider.vehicleno].map((part) => (part ?? '').trim()).filter(Boolean).join(' · '); } /** * Which fleet to ask for, given the merchant and the source they chose. * * Its own function so the rule is testable and so the picker and anything that * follows it cannot drift apart on it. Never returns a region: `getriders` * scoped by city answers "who is on duty in Coimbatore", which for a merchant * is 82 riders belonging to other companies. */ export function riderScope(input: { tenantid: number; partnerid: number; source: 'own' | 'partner'; }): { tenantid?: number | undefined; partnerid?: number | undefined } { if (input.source === 'partner' && input.partnerid > 0) return { partnerid: input.partnerid }; return { tenantid: input.tenantid || undefined }; }