481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
import type { CustomerInfo } from '@/api/customers';
|
|
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
|
|
import { awaitingRider, assignedFrom } from './assignDelivery';
|
|
import { isRealName } from './orderStatus';
|
|
|
|
/**
|
|
* A day's dispatch, grouped three ways.
|
|
*
|
|
* By store, by customer, and by rider — the same stops read three ways, because
|
|
* an operator asks three different questions of them: which shop is busy, who
|
|
* is waiting, and what is each rider carrying.
|
|
*
|
|
* ── Why a stop is not just a delivery ───────────────────────────────────────
|
|
*
|
|
* A `deliveries` row only exists once somebody has been assigned. An order that
|
|
* nobody has picked up yet has no delivery row at all, so a board built on
|
|
* `getdeliveries` alone shows an empty day while orders pile up in Orders —
|
|
* R mart had 29 waiting on 5 September and this page said "Nothing out on this
|
|
* day". Worse, its "Not assigned" count was structurally incapable of ever
|
|
* being anything but zero.
|
|
*
|
|
* So a stop is one of two things, and the union is deliberate rather than a
|
|
* synthetic delivery row: the detail drawer needs the REAL order to render an
|
|
* order, and faking a `DeliveryRow` for it would hand the drawer a job that has
|
|
* no delivery id, no rider and no lifecycle stamps.
|
|
*
|
|
* ── Seeded from the roster, not from the stops ──────────────────────────────
|
|
*
|
|
* Both the store and customer views start from the full list — every branch,
|
|
* every customer — and fold the day's stops into it. A shop with nothing out
|
|
* today still appears, showing zero. Grouping only what came back would make a
|
|
* quiet branch vanish from the board entirely, which reads as a data problem
|
|
* rather than a quiet morning.
|
|
*
|
|
* Pure, so every rule below is testable without a map or a network.
|
|
*/
|
|
|
|
export type ViewMode = 'stores' | 'customers' | 'riders';
|
|
|
|
/** A job somebody is carrying, or an order still waiting for a rider. */
|
|
export type Stop =
|
|
| { kind: 'delivery'; row: DeliveryRow }
|
|
| { kind: 'order'; row: OrderRow };
|
|
|
|
/**
|
|
* The status a waiting order shows.
|
|
*
|
|
* Its own word, not one borrowed from the delivery ladder. `pending` there
|
|
* means "assigned, not yet collected" — a rider already has it — so reusing it
|
|
* for work nobody has touched would make the two indistinguishable on a board
|
|
* whose whole job is telling them apart.
|
|
*/
|
|
export const WAITING = 'waiting';
|
|
|
|
export interface Group {
|
|
id: string;
|
|
name: string;
|
|
/** A second line — the branch a customer belongs to, or a suburb. */
|
|
detail?: string;
|
|
stops: Stop[];
|
|
/** Counts by lowercase status, `waiting` included. */
|
|
statuses: Record<string, number>;
|
|
delivered: number;
|
|
/** Distinct riders touching this group's work. */
|
|
riders: Set<string>;
|
|
value: number;
|
|
}
|
|
|
|
const lower = (value: string | undefined) => (value ?? '').trim().toLowerCase();
|
|
|
|
/* ── Reading a stop, whichever kind it is ────────────────────────────────── */
|
|
|
|
export function stopStatus(stop: Stop): string {
|
|
return stop.kind === 'order' ? WAITING : lower(stop.row.orderstatus) || 'unknown';
|
|
}
|
|
|
|
/**
|
|
* A key that is stable and unique across both kinds.
|
|
*
|
|
* Prefixed by kind because the two id spaces overlap — delivery 128302 and
|
|
* order header 128302 are different things, and a bare number would collide as
|
|
* a React key and silently drop a row.
|
|
*/
|
|
export function stopKey(stop: Stop): string {
|
|
return stop.kind === 'delivery'
|
|
? `d${stop.row.deliveryid}`
|
|
: `o${stop.row.orderheaderid ?? stop.row.orderid ?? ''}`;
|
|
}
|
|
|
|
export function stopOrderId(stop: Stop): string {
|
|
return stop.row.orderid ?? '';
|
|
}
|
|
|
|
export function stopLocation(stop: Stop): number | undefined {
|
|
return stop.row.locationid;
|
|
}
|
|
|
|
export function stopCustomer(stop: Stop): string {
|
|
return stop.row.deliverycustomer ?? '';
|
|
}
|
|
|
|
export function stopContact(stop: Stop): string {
|
|
return stop.row.deliverycontactno ?? '';
|
|
}
|
|
|
|
export function stopAddress(stop: Stop): string {
|
|
return stop.row.deliveryaddress ?? '';
|
|
}
|
|
|
|
/**
|
|
* What the stop is worth.
|
|
*
|
|
* A delivery carries `deliveryamt`. An order has not had one written yet, so it
|
|
* uses the chain `OrderRow` documents — `ordervalue || orderamount ||
|
|
* deliveryamt` — because which field is populated depends on the endpoint the
|
|
* row came through. Without it every waiting order would read ₹0 and the day's
|
|
* value would understate itself by exactly the work that has not gone out.
|
|
*/
|
|
export function stopValue(stop: Stop): number {
|
|
if (stop.kind === 'delivery') return stop.row.deliveryamt ?? 0;
|
|
return stop.row.ordervalue || stop.row.orderamount || stop.row.deliveryamt || 0;
|
|
}
|
|
|
|
/** The rider carrying it, or undefined while nobody is. */
|
|
export function stopRider(stop: Stop): number | undefined {
|
|
return stop.kind === 'delivery' ? stop.row.userid : undefined;
|
|
}
|
|
|
|
/**
|
|
* When the stop entered the day — assigned, for a delivery; placed, for an
|
|
* order. Used only for ordering within a group, so the two being different
|
|
* clocks does not matter; both answer "which came first".
|
|
*/
|
|
export function stopTime(stop: Stop): string {
|
|
return stop.kind === 'delivery'
|
|
? (stop.row.assigntime ?? '')
|
|
: (stop.row.orderdate ?? '');
|
|
}
|
|
|
|
/* ── Building the day ────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Every stop for the day: what is out, plus what is still waiting to go.
|
|
*
|
|
* The waiting half comes from `awaitingRider`, the same rule the Orders page
|
|
* uses for its assign bar, so the two screens cannot disagree about what counts
|
|
* as unassigned. Reusing it is the point — a second definition here would drift
|
|
* the first time either was touched.
|
|
*/
|
|
export function toStops(
|
|
deliveries: readonly DeliveryRow[],
|
|
orders: readonly OrderRow[],
|
|
): Stop[] {
|
|
const assigned = assignedFrom(deliveries);
|
|
return [
|
|
...deliveries.map((row): Stop => ({ kind: 'delivery', row })),
|
|
...awaitingRider(orders, assigned).map((row): Stop => ({ kind: 'order', row })),
|
|
];
|
|
}
|
|
|
|
function blank(id: string, name: string, detail?: string): Group {
|
|
return {
|
|
id,
|
|
name,
|
|
...(detail ? { detail } : {}),
|
|
stops: [],
|
|
statuses: {},
|
|
delivered: 0,
|
|
riders: new Set(),
|
|
value: 0,
|
|
};
|
|
}
|
|
|
|
function fold(group: Group, stop: Stop): void {
|
|
group.stops.push(stop);
|
|
const status = stopStatus(stop);
|
|
group.statuses[status] = (group.statuses[status] ?? 0) + 1;
|
|
if (status === 'delivered') group.delivered += 1;
|
|
group.value += stopValue(stop);
|
|
const rider = stopRider(stop);
|
|
if (rider) group.riders.add(String(rider));
|
|
}
|
|
|
|
/**
|
|
* The most common value in `ridername` that is not a delivery status.
|
|
*
|
|
* Returns undefined when the column held nothing but statuses, so the caller
|
|
* keeps whatever fallback it had rather than being handed a wrong name.
|
|
*/
|
|
function commonRiderName(stops: readonly Stop[]): string | undefined {
|
|
const counts = new Map<string, number>();
|
|
for (const stop of stops) {
|
|
if (stop.kind !== 'delivery') continue;
|
|
const name = stop.row.ridername?.trim();
|
|
if (!name || !isRealName(name)) continue;
|
|
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
}
|
|
let best: string | undefined;
|
|
let most = 0;
|
|
for (const [name, count] of counts) {
|
|
if (count > most) {
|
|
best = name;
|
|
most = count;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** Busiest first, then alphabetical, so the board does not reshuffle randomly. */
|
|
function order(groups: Group[]): Group[] {
|
|
return groups.sort((a, b) => b.stops.length - a.stops.length || a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/**
|
|
* By store.
|
|
*
|
|
* Seeded from the branch list. A stop whose branch is not in the list still
|
|
* gets a group — a branch can be deactivated while its deliveries are out, and
|
|
* dropping them would hide live work.
|
|
*/
|
|
export function groupByStore(
|
|
stops: readonly Stop[],
|
|
branches: readonly TenantLocation[],
|
|
): Group[] {
|
|
const map = new Map<string, Group>();
|
|
for (const branch of branches) {
|
|
map.set(
|
|
String(branch.locationid),
|
|
blank(
|
|
String(branch.locationid),
|
|
branch.locationname || `Branch ${branch.locationid}`,
|
|
branch.suburb || branch.city,
|
|
),
|
|
);
|
|
}
|
|
|
|
for (const stop of stops) {
|
|
const id = String(stopLocation(stop) ?? 'unknown');
|
|
let group = map.get(id);
|
|
if (!group) {
|
|
group = blank(id, stop.row.locationname || `Branch ${id}`);
|
|
map.set(id, group);
|
|
}
|
|
fold(group, stop);
|
|
}
|
|
|
|
return order([...map.values()]);
|
|
}
|
|
|
|
/**
|
|
* By customer.
|
|
*
|
|
* Seeded from the customer book, and keyed on the CONTACT NUMBER rather than
|
|
* the customer id. `deliveries.deliverycustomerid` is 0 on every row this
|
|
* system has ever written — the app never sets it — so an id join finds
|
|
* nothing. The phone number is what both sides actually carry.
|
|
*/
|
|
export function groupByCustomer(
|
|
stops: readonly Stop[],
|
|
customers: readonly CustomerInfo[],
|
|
branchName: (locationid: number | undefined) => string | undefined,
|
|
): Group[] {
|
|
const map = new Map<string, Group>();
|
|
const phone = (value: string | undefined) => (value ?? '').replace(/\D/g, '').slice(-10);
|
|
|
|
for (const customer of customers) {
|
|
const key = phone(customer.contactno);
|
|
if (!key) continue;
|
|
const name = [customer.firstname, customer.lastname].filter(Boolean).join(' ').trim();
|
|
map.set(key, blank(key, name || `Customer ${customer.customerid}`, customer.contactno));
|
|
}
|
|
|
|
for (const stop of stops) {
|
|
const key = phone(stopContact(stop)) || `row-${stopKey(stop)}`;
|
|
let group = map.get(key);
|
|
if (!group) {
|
|
/*
|
|
A stop for somebody not in the book — a guest checkout, or a customer
|
|
registered against another branch. Kept: it is real work.
|
|
|
|
Falls back to the drop ADDRESS before the word "Customer". A good number
|
|
of production rows carry neither a name nor a phone (three of the three
|
|
out on 10 June), and naming them all "Customer" produced a rail of
|
|
identical entries that could not be told apart or usefully clicked. The
|
|
address is the one thing every one of them has, and it is what an operator
|
|
would use to identify the stop anyway.
|
|
*/
|
|
group = blank(
|
|
key,
|
|
stopCustomer(stop) || stopAddress(stop) || 'Customer',
|
|
stopContact(stop),
|
|
);
|
|
map.set(key, group);
|
|
}
|
|
if (!group.detail) group.detail = branchName(stopLocation(stop));
|
|
fold(group, stop);
|
|
}
|
|
|
|
// Customers with nothing today would flood the board — thousands of rows for
|
|
// a handful of stops — so unlike stores, the empty ones are dropped.
|
|
return order([...map.values()].filter((group) => group.stops.length > 0));
|
|
}
|
|
|
|
/**
|
|
* By rider — the round.
|
|
*
|
|
* Ordered by `assigntime`, which is safe here and is NOT safe in the system
|
|
* this pattern came from: theirs mapped `assigntime` onto the row's
|
|
* last-modified column, so any status change moved an order between groups.
|
|
* Ours is a distinct column written once by `createdeliveries` and never
|
|
* re-stamped. Do not "simplify" it to `updated`.
|
|
*
|
|
* Unassigned work is its own group and sorts first, because it is the only
|
|
* group anybody has to act on. It is also the only group that can hold order
|
|
* stops: everything else here is, by definition, carried by somebody.
|
|
*/
|
|
export const UNASSIGNED = 'unassigned';
|
|
|
|
export function groupByRider(stops: readonly Stop[]): Group[] {
|
|
const map = new Map<string, Group>();
|
|
|
|
for (const stop of stops) {
|
|
const rider = stopRider(stop);
|
|
const id = rider ? String(rider) : UNASSIGNED;
|
|
let group = map.get(id);
|
|
if (!group) {
|
|
const detail = stop.kind === 'delivery' ? stop.row.ridercontact : undefined;
|
|
// Named once the group is complete — see below.
|
|
group = blank(id, id === UNASSIGNED ? 'Waiting for a rider' : `Rider ${id}`, detail);
|
|
map.set(id, group);
|
|
}
|
|
fold(group, stop);
|
|
}
|
|
|
|
/*
|
|
Named from the whole group, not from whichever stop arrived first.
|
|
|
|
`ridername` is not reliably a name: every rider on tenant 916 has BOTH their
|
|
name and a delivery status in that column, and for two of the five the status
|
|
is the MORE common value —
|
|
|
|
883 { "Rajan": 21, "delivered": 42 }
|
|
897 { "Varun": 69, "delivered": 75 }
|
|
1111 { "Murali": 33, "delivered": 74, "cancelled": 1 }
|
|
1114 { "Tamilazhagan": 37, "delivered": 63 }
|
|
|
|
Taking the first stop's value put a rider called "delivered" in the rail, next
|
|
to the real riders, carrying 100 of the day's stops. Grouping was never
|
|
affected — that is on `userid` — but the label was, and the rail is the list
|
|
an operator picks from. So statuses are excluded by vocabulary and the most
|
|
common of what survives is the name; a rider whose every row carried a status
|
|
keeps their id, which is at least honest about being an id.
|
|
*/
|
|
for (const group of map.values()) {
|
|
group.stops.sort((a, b) => stopTime(a).localeCompare(stopTime(b)));
|
|
if (group.id !== UNASSIGNED) {
|
|
const name = commonRiderName(group.stops);
|
|
if (name) group.name = name;
|
|
}
|
|
}
|
|
|
|
const groups = order([...map.values()]);
|
|
// Unassigned to the top regardless of size — it is the work queue.
|
|
return groups.sort((a, b) => Number(b.id === UNASSIGNED) - Number(a.id === UNASSIGNED));
|
|
}
|
|
|
|
/* ── Rider tracking ──────────────────────────────────────────────────────── */
|
|
|
|
export interface LastSeen {
|
|
lat: number;
|
|
lon: number;
|
|
/** The delivery whose status update carried the position. */
|
|
deliveryid: number;
|
|
orderid?: string;
|
|
/** The status the rider was reporting when it was captured. */
|
|
status: string;
|
|
/** When, if the row carries a usable stamp. */
|
|
at: string | null;
|
|
}
|
|
|
|
/**
|
|
* Where a rider last reported themselves.
|
|
*
|
|
* NOT a live feed and must never be drawn as one. `riderslat`/`riderslon` are
|
|
* written by `updatedelivery`, so a position arrives only when the rider moves
|
|
* a job along — a handful of points per delivery, not a trail. Nothing in the
|
|
* backend ingests a position on a timer, and `getriders` returns none at all.
|
|
*
|
|
* Sparse in practice: 1 of 7 production deliveries carries one. The empty case
|
|
* is the common case and has to read as "not reported" rather than as a blank.
|
|
*
|
|
* Order stops are skipped outright — nobody is carrying them, so there is no
|
|
* position to report and no rider to attribute one to.
|
|
*/
|
|
export function lastSeen(stops: readonly Stop[]): LastSeen | null {
|
|
const positioned = stops
|
|
.filter((stop): stop is { kind: 'delivery'; row: DeliveryRow } => stop.kind === 'delivery')
|
|
.map((stop) => stop.row)
|
|
.filter((row) => {
|
|
const lat = Number(row.riderslat ?? '');
|
|
const lon = Number(row.riderslon ?? '');
|
|
return Number.isFinite(lat) && Number.isFinite(lon) && lat !== 0 && lon !== 0;
|
|
})
|
|
.sort((a, b) => stampOf(b).localeCompare(stampOf(a)));
|
|
|
|
const row = positioned[0];
|
|
if (!row) return null;
|
|
|
|
return {
|
|
lat: Number(row.riderslat),
|
|
lon: Number(row.riderslon),
|
|
deliveryid: row.deliveryid,
|
|
...(row.orderid ? { orderid: row.orderid } : {}),
|
|
status: lower(row.orderstatus) || 'unknown',
|
|
at: stampOf(row) || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The most recent lifecycle stamp on a delivery.
|
|
*
|
|
* Walked newest-first through the ladder rather than trusting one field: a row
|
|
* that has been delivered carries every earlier stamp too, and the latest is
|
|
* the one that says when the position was captured.
|
|
*/
|
|
function stampOf(row: DeliveryRow): string {
|
|
return (
|
|
row.deliverytime ||
|
|
row.pickuptime ||
|
|
row.arrivaltime ||
|
|
row.starttime ||
|
|
row.assigntime ||
|
|
''
|
|
);
|
|
}
|
|
|
|
/* ── The day's headline numbers ──────────────────────────────────────────── */
|
|
|
|
export interface DayTotals {
|
|
stops: number;
|
|
delivered: number;
|
|
outstanding: number;
|
|
unassigned: number;
|
|
riders: number;
|
|
value: number;
|
|
}
|
|
|
|
export function dayTotals(stops: readonly Stop[]): DayTotals {
|
|
const riders = new Set<string>();
|
|
let delivered = 0;
|
|
let unassigned = 0;
|
|
let outstanding = 0;
|
|
let value = 0;
|
|
|
|
for (const stop of stops) {
|
|
const status = stopStatus(stop);
|
|
if (status === 'delivered') delivered += 1;
|
|
// Everything still to do — cancelled work is finished, not outstanding, and
|
|
// a waiting order is the most outstanding thing on the board.
|
|
if (status !== 'delivered' && status !== 'cancelled') outstanding += 1;
|
|
const rider = stopRider(stop);
|
|
if (rider) riders.add(String(rider));
|
|
else unassigned += 1;
|
|
value += stopValue(stop);
|
|
}
|
|
|
|
return { stops: stops.length, delivered, outstanding, unassigned, riders: riders.size, value };
|
|
}
|
|
|
|
/** One day as Fiesta wants it. */
|
|
export function ymd(date: Date): string {
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
}
|
|
|
|
export function isToday(day: string): boolean {
|
|
return day === ymd(new Date());
|
|
}
|
|
|
|
|