/** * The dispatch preview's data model. * * The optimiser answers with a zoned tree — `zones[].riders[].orders[]` — plus * a flat `details[]` list, and every edit on the preview page has to keep both * in step: the board renders from the tree, and the commit payload is built * from the flat list. Doing that in one place is what stops an order being * shown against one rider and assigned to another. * * Ported from the Express Console's `dispatch/Preview.js`, where these were * module-local; the logic is unchanged. */ // Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for // the Reconcile tab UI and the reconcile-API payload. export const extractRiders = (previewData) => { if (!previewData) return []; const map = new Map(); // De-dupe by orderid across the whole tree. A rider can legitimately appear // in multiple zones (one per delivery suburb), so the same rider_id is // visited more than once. Without this guard, any stale copy left behind // by applyReconcileResponse gets concatenated into the rider's orders and // the same orderid is sent twice to /deliveries/createdeliveries. const seenOrderIds = new Set(); const push = (riderId, riderName, orders) => { if (riderId == null) return; const key = String(riderId); if (!map.has(key)) { map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] }); } const entry = map.get(key); (orders || []).forEach((o, oIdx) => { const resolvedOrderId = o?.orderid || o?.bookingno || o?.bookingid || o?.order_id || o?.booking_id || o?.consignment_id || o?.id || `ord-${oIdx + 1}`; const oid = String(resolvedOrderId); if (oid) { if (seenOrderIds.has(oid)) return; seenOrderIds.add(oid); } entry.orders.push({ ...o, orderid: resolvedOrderId }); }); if (!entry.rider_name && riderName) entry.rider_name = riderName; }; if (Array.isArray(previewData.zones) && previewData.zones.length) { previewData.zones.forEach((z) => { (z.riders || []).forEach((r) => { const id = r.rider_id ?? r.userid; const name = r.rider_name || r.username || `Rider ${id}`; push(id, name, r.orders); }); }); } else if (Array.isArray(previewData.details)) { previewData.details.forEach((o) => { const id = o.rider_id ?? o.userid; const name = o.rider_name || o.ridername || `Rider ${id}`; push(id, name, [o]); }); } return Array.from(map.values()); }; // Reverse of extractRiders — flatten rider-grouped list into a details-style // array (used as the Assign Orders payload). export const flattenRiders = (riders) => { const out = []; riders.forEach((r) => { // Go backend types Deliveries.userid as int — coerce here so any // upstream string (AI response, riders API, change-rider edit) gets // normalised before the JSON body is built. const ridNum = Number(r.rider_id); const rid = Number.isFinite(ridNum) ? ridNum : r.rider_id; (r.orders || []).forEach((o) => { out.push({ ...o, rider_id: rid, userid: rid, rider_name: r.rider_name, rider: r.rider_name }); }); }); return out; }; // Move one order from oldRiderId -> newRiderId inside dispatchPreviewData. // Mutates both the zones[].riders[].orders[] tree (so the Dispatch tab // renders the change) AND the flat details[] list (so Assign Orders picks // it up). Returns a NEW preview object (immutable update). export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName }) => { if (!preview) return preview; const next = JSON.parse(JSON.stringify(preview)); // 1) Update flat details list if (Array.isArray(next.details)) { next.details = next.details.map((o) => String(o.orderid) === String(orderId) ? { ...o, rider_id: newRiderId, userid: newRiderId, rider_name: newRiderName, rider: newRiderName } : o ); } // 2) Move within zones[].riders[].orders[] if (Array.isArray(next.zones)) { let movedOrder = null; let homeZoneIdx = -1; for (let zi = 0; zi < next.zones.length && !movedOrder; zi++) { const zone = next.zones[zi]; if (!Array.isArray(zone.riders)) continue; for (let ri = 0; ri < zone.riders.length && !movedOrder; ri++) { const r = zone.riders[ri]; if (!Array.isArray(r.orders)) continue; const oi = r.orders.findIndex((o) => String(o.orderid) === String(orderId)); if (oi !== -1) { movedOrder = r.orders[oi]; r.orders.splice(oi, 1); homeZoneIdx = zi; // A rider left with zero orders after this move is a ghost entry — // Dispatch's rider list renders every zone.riders[] entry // unconditionally, so it would keep showing as a clickable // 0-trips/0km/₹0 card with nothing inside once its last order is // reassigned elsewhere. Drop it from the zone entirely. if (r.orders.length === 0) { zone.riders.splice(ri, 1); } } } } if (movedOrder) { const updated = { ...movedOrder, rider_id: newRiderId, userid: newRiderId, rider_name: newRiderName, rider: newRiderName }; let placed = false; for (const zone of next.zones) { if (!Array.isArray(zone.riders)) continue; const target = zone.riders.find( (r) => String(r.rider_id ?? r.userid) === String(newRiderId) ); if (target) { target.orders = target.orders || []; target.orders.push(updated); placed = true; break; } } if (!placed && homeZoneIdx >= 0) { next.zones[homeZoneIdx].riders.push({ rider_id: newRiderId, userid: newRiderId, rider_name: newRiderName, orders: [updated] }); } } } return next; }; // Merge a reconcile-API response { riders:[{rider_id, orders}] } back into // dispatchPreviewData. Replaces each rider's orders[] in zones (preserving // zone containment), then rebuilds the flat details list from the new tree. export const applyReconcileResponse = (preview, response) => { if (!preview || !Array.isArray(response?.riders)) return preview; const next = JSON.parse(JSON.stringify(preview)); const newOrdersByRider = new Map( response.riders.map((r) => [String(r.rider_id), r.orders || []]) ); if (Array.isArray(next.zones) && next.zones.length) { // Pass 1: wipe every existing copy of a responding rider's orders across // ALL zones. The server's reconciled list is the single source of truth, // and a rider can be present in multiple zones (one per delivery suburb). // The previous "update first match, delete from map" loop left stale // copies in the other zones, which extractRiders then concatenated into // duplicate orderids — surfacing as duplicate deliveries on Assign. next.zones.forEach((zone) => { if (!Array.isArray(zone.riders)) return; zone.riders.forEach((r) => { const key = String(r.rider_id ?? r.userid); if (newOrdersByRider.has(key)) r.orders = []; }); }); // Pass 2: drop the reconciled orders onto the first zone that already // lists the rider. If the rider isn't anywhere in the tree, append a // fresh rider entry to zone[0]. newOrdersByRider.forEach((orders, riderKey) => { let placed = false; for (const zone of next.zones) { if (!Array.isArray(zone.riders)) continue; const target = zone.riders.find( (r) => String(r.rider_id ?? r.userid) === riderKey ); if (target) { target.orders = orders; placed = true; break; } } if (!placed) { const target = next.zones[0]; target.riders = target.riders || []; target.riders.push({ rider_id: Number(riderKey) || riderKey, rider_name: orders[0]?.rider_name || `Rider ${riderKey}`, orders }); } }); // Same ghost-rider cleanup as moveOrderInPreviewData: if the reconcile // response came back with an empty orders[] for a rider (every stop it // had got reassigned elsewhere during reconciliation), don't leave that // rider sitting in the tree as a 0-trips/0km/₹0 card with nothing inside. next.zones.forEach((zone) => { if (!Array.isArray(zone.riders)) return; zone.riders = zone.riders.filter((r) => Array.isArray(r.orders) && r.orders.length > 0); }); } else { next.zones = [ { zone_name: 'Reconciled', riders: response.riders.map((r) => ({ rider_id: r.rider_id, rider_name: r.rider_name || `Rider ${r.rider_id}`, orders: r.orders || [] })) } ]; } // Rebuild flat details from the updated zones->riders->orders tree. const flatDetails = []; next.zones.forEach((zone) => { (zone.riders || []).forEach((r) => { (r.orders || []).forEach((o) => { flatDetails.push({ ...o, rider_id: r.rider_id, userid: r.rider_id, rider_name: r.rider_name, rider: r.rider_name }); }); }); }); next.details = flatDetails; return next; }; // The delivery amount, recomputed from the rate card the solver echoed back. // Applied at render time so the commit payload always reflects the current // state of the tree without a synchronising effect. export function computeDeliveryAmounts(list) { return list.map((item) => { const cumulativeKms = Number(item.cumulativekms || 0); const minKm = Number(item.minkm || 0); const basePrice = Number(item.baseprice || 0); const pricePerKm = Number(item.priceperkm || 0); if (cumulativeKms <= minKm) return { ...item, deliveryamt: basePrice }; return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice }; }); }