/** * 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(); 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[], 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"); }