deliveries
This commit is contained in:
345
src/features/store-admin/assignDelivery.ts
Normal file
345
src/features/store-admin/assignDelivery.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
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())}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Just the date half, for `deliverydate`. */
|
||||
export function dateOf(now = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the `createdeliveries` array.
|
||||
*
|
||||
* `Pickupaddress` is capitalised. That is not a typo here — it is the JSON tag
|
||||
* on `models.Deliveries`, alone among fifty lowercase ones, and sending
|
||||
* `pickupaddress` instead means the address is simply dropped.
|
||||
*/
|
||||
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, and the moment it was handed out. Both are
|
||||
// written by us: the backend stamps neither on create.
|
||||
deliverydate: dateOf(now),
|
||||
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(' · ');
|
||||
}
|
||||
Reference in New Issue
Block a user