369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
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<number>): 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<number> {
|
|
const out = new Set<number>();
|
|
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<number>,
|
|
): 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<number>,
|
|
): 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<number>,
|
|
): { 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 };
|
|
}
|