dispatch fix

This commit is contained in:
2026-09-05 15:38:40 +05:30
parent 5006a4f0d4
commit d3785cc303
8 changed files with 1407 additions and 0 deletions

View File

@@ -0,0 +1,315 @@
import type { CustomerInfo } from '@/api/customers';
import type { DeliveryRow, TenantLocation } from '@/api/types';
/**
* A day's deliveries, grouped three ways.
*
* By store, by customer, and by rider — the same rows 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.
*
* ── Seeded from the roster, not from the deliveries ─────────────────────────
*
* Both the store and customer views start from the full list — every branch,
* every customer — and fold the day's deliveries 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';
export interface Group {
id: string;
name: string;
/** A second line — the branch a customer belongs to, or a suburb. */
detail?: string;
deliveries: DeliveryRow[];
/** Counts by lowercase delivery status. */
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();
/** The status ladder, lowercased. `pending` here means "assigned, not collected". */
export function statusOf(row: DeliveryRow): string {
return lower(row.orderstatus) || 'unknown';
}
function blank(id: string, name: string, detail?: string): Group {
return {
id,
name,
...(detail ? { detail } : {}),
deliveries: [],
statuses: {},
delivered: 0,
riders: new Set(),
value: 0,
};
}
function fold(group: Group, row: DeliveryRow): void {
group.deliveries.push(row);
const status = statusOf(row);
group.statuses[status] = (group.statuses[status] ?? 0) + 1;
if (status === 'delivered') group.delivered += 1;
group.value += row.deliveryamt ?? 0;
const rider = String(row.userid ?? '') || (row.ridername ?? '');
if (rider) group.riders.add(rider);
}
/** Busiest first, then alphabetical, so the board does not reshuffle randomly. */
function order(groups: Group[]): Group[] {
return groups.sort(
(a, b) => b.deliveries.length - a.deliveries.length || a.name.localeCompare(b.name),
);
}
/**
* By store.
*
* Seeded from the branch list. A delivery 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(
deliveries: readonly DeliveryRow[],
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 row of deliveries) {
const id = String(row.locationid ?? 'unknown');
let group = map.get(id);
if (!group) {
group = blank(id, row.locationname || `Branch ${id}`);
map.set(id, group);
}
fold(group, row);
}
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(
deliveries: readonly DeliveryRow[],
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 row of deliveries) {
const key = phone(row.deliverycontactno) || `row-${row.deliveryid}`;
let group = map.get(key);
if (!group) {
/*
A delivery to 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,
row.deliverycustomer || row.deliveryaddress || 'Customer',
row.deliverycontactno,
);
map.set(key, group);
}
if (!group.detail) group.detail = branchName(row.locationid);
fold(group, row);
}
// Customers with nothing today would flood the board — thousands of rows for
// a handful of deliveries — so unlike stores, the empty ones are dropped.
return order([...map.values()].filter((group) => group.deliveries.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.
*/
export const UNASSIGNED = 'unassigned';
export function groupByRider(deliveries: readonly DeliveryRow[]): Group[] {
const map = new Map<string, Group>();
for (const row of deliveries) {
const id = String(row.userid ?? '') || UNASSIGNED;
let group = map.get(id);
if (!group) {
group = blank(id, id === UNASSIGNED ? 'Not assigned' : row.ridername || `Rider ${id}`, row.ridercontact);
map.set(id, group);
}
fold(group, row);
}
for (const group of map.values()) {
group.deliveries.sort((a, b) => (a.assigntime ?? '').localeCompare(b.assigntime ?? ''));
}
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.
*/
export function lastSeen(deliveries: readonly DeliveryRow[]): LastSeen | null {
const withPosition = deliveries
.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 = withPosition[0];
if (!row) return null;
return {
lat: Number(row.riderslat),
lon: Number(row.riderslon),
deliveryid: row.deliveryid,
...(row.orderid ? { orderid: row.orderid } : {}),
status: statusOf(row),
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(deliveries: readonly DeliveryRow[]): DayTotals {
const riders = new Set<string>();
let delivered = 0;
let unassigned = 0;
let value = 0;
for (const row of deliveries) {
const status = statusOf(row);
if (status === 'delivered') delivered += 1;
if (!row.userid) unassigned += 1;
if (row.userid) riders.add(String(row.userid));
value += row.deliveryamt ?? 0;
}
return {
stops: deliveries.length,
delivered,
// Everything still to do — cancelled work is finished, not outstanding.
outstanding: deliveries.filter((row) => {
const status = statusOf(row);
return status !== 'delivered' && status !== 'cancelled';
}).length,
unassigned,
riders: riders.size,
value,
};
}
/** One day as Fiesta wants it, and the stepper's arithmetic. */
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 shiftDay(day: string, by: number): string {
const [y, m, d] = day.split('-').map(Number);
// Local midnight, then step. Constructing from the parts rather than parsing
// the string avoids the UTC shift that would move the whole board a day.
const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
date.setDate(date.getDate() + by);
return ymd(date);
}
export function isToday(day: string): boolean {
return day === ymd(new Date());
}