Files
daily_console_web/src/features/store-admin/assignDelivery.ts
2026-09-09 15:43:23 +05:30

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 };
}