Files
daily_console_web/src/features/store-admin/dispatchModel.ts
2026-09-10 17:57:00 +05:30

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