dispatch fix
This commit is contained in:
315
src/features/store-admin/dispatchModel.ts
Normal file
315
src/features/store-admin/dispatchModel.ts
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user