99 lines
3.5 KiB
TypeScript
99 lines
3.5 KiB
TypeScript
/**
|
||
* Driver dispatch: daily run sheet, stop ordering and route links.
|
||
*
|
||
* Ordering: by slot window first (promises come first), then nearest-neighbour
|
||
* within each window starting from the previous stop. This is a pragmatic
|
||
* heuristic, not an optimal VRP solver — export to a route optimiser for large fleets.
|
||
*/
|
||
import type { Booking, LocationConfig } from "./types";
|
||
import { haversineKm } from "./zones";
|
||
|
||
export interface RunStop {
|
||
seq: number;
|
||
booking: Booking;
|
||
window: string;
|
||
legKm: number | null;
|
||
}
|
||
|
||
export interface RunSheet {
|
||
locationId: string;
|
||
date: string;
|
||
stops: RunStop[];
|
||
totalKm: number | null;
|
||
unlocated: Booking[];
|
||
mapsUrls: string[];
|
||
}
|
||
|
||
export function buildRunSheet(bookings: Booking[], loc: LocationConfig, date: string): RunSheet {
|
||
const todays = bookings.filter(
|
||
(b) => b.method === "delivery" && b.locationId === loc.id && b.date === date && b.status === "confirmed",
|
||
);
|
||
const located = todays.filter((b) => b.address?.lat != null && b.address?.lng != null);
|
||
const unlocated = todays.filter((b) => !located.includes(b));
|
||
|
||
const windows = new Map<string, Booking[]>();
|
||
for (const b of located) {
|
||
const w = `${b.slotStart}-${b.slotEnd}`;
|
||
windows.set(w, [...(windows.get(w) ?? []), b]);
|
||
}
|
||
const ordered: RunStop[] = [];
|
||
let cur = { lat: loc.lat ?? 0, lng: loc.lng ?? 0 };
|
||
let total = 0;
|
||
const hasOrigin = loc.lat != null && loc.lng != null;
|
||
for (const w of [...windows.keys()].sort()) {
|
||
const pool = [...windows.get(w)!];
|
||
while (pool.length) {
|
||
let best = 0;
|
||
let bestD = Infinity;
|
||
pool.forEach((b, i) => {
|
||
const d = haversineKm(cur.lat, cur.lng, b.address!.lat!, b.address!.lng!);
|
||
if (d < bestD) (bestD = d), (best = i);
|
||
});
|
||
const [b] = pool.splice(best, 1);
|
||
const leg = hasOrigin || ordered.length ? bestD : null;
|
||
if (leg != null) total += leg;
|
||
ordered.push({ seq: ordered.length + 1, booking: b, window: w, legKm: leg });
|
||
cur = { lat: b.address!.lat!, lng: b.address!.lng! };
|
||
}
|
||
}
|
||
return {
|
||
locationId: loc.id,
|
||
date,
|
||
stops: ordered,
|
||
totalKm: ordered.length ? Math.round(total * 10) / 10 : null,
|
||
unlocated,
|
||
mapsUrls: mapsUrls(loc, ordered),
|
||
};
|
||
}
|
||
|
||
/** Google Maps directions links, chunked to the 9-waypoint limit of the URL API. */
|
||
export function mapsUrls(loc: LocationConfig, stops: RunStop[]): string[] {
|
||
if (!stops.length) return [];
|
||
const pt = (b: Booking) => `${b.address!.lat},${b.address!.lng}`;
|
||
const origin = loc.lat != null ? `${loc.lat},${loc.lng}` : encodeURIComponent(loc.address);
|
||
const urls: string[] = [];
|
||
let from = origin;
|
||
for (let i = 0; i < stops.length; i += 10) {
|
||
const chunk = stops.slice(i, i + 10);
|
||
const dest = pt(chunk[chunk.length - 1].booking);
|
||
const way = chunk.slice(0, -1).map((s) => pt(s.booking)).join("|");
|
||
urls.push(
|
||
`https://www.google.com/maps/dir/?api=1&origin=${from}&destination=${dest}` +
|
||
(way ? `&waypoints=${encodeURIComponent(way)}` : "") +
|
||
`&travelmode=driving`,
|
||
);
|
||
from = dest;
|
||
}
|
||
return urls;
|
||
}
|
||
|
||
/** RFC 4180 CSV with a UTF-8 BOM so Excel opens accents/Tamil text correctly. */
|
||
export function toCsv(rows: Record<string, unknown>[], columns?: string[]): string {
|
||
const cols = columns ?? Object.keys(rows[0] ?? {});
|
||
const esc = (v: unknown) => {
|
||
const s = v == null ? "" : String(v);
|
||
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||
};
|
||
return "" + [cols.join(","), ...rows.map((r) => cols.map((c) => esc(r[c])).join(","))].join("\r\n");
|
||
}
|