diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..602fb76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.db +*.sqlite3 +*.pyc diff --git a/app/routes/__pycache__/optimization.cpython-312.pyc b/app/routes/__pycache__/optimization.cpython-312.pyc deleted file mode 100644 index 769eeee..0000000 Binary files a/app/routes/__pycache__/optimization.cpython-312.pyc and /dev/null differ diff --git a/app/routes/optimization.py b/app/routes/optimization.py index ed8ee2f..e26912d 100644 --- a/app/routes/optimization.py +++ b/app/routes/optimization.py @@ -284,6 +284,8 @@ async def reconcile_steps(body: Any = Body(default=None)): ) from app.services.routing.route_optimizer import RouteOptimizer + from datetime import timedelta + from dateutil.parser import parse as parse_date results = [] @@ -331,15 +333,17 @@ async def reconcile_steps(body: Any = Body(default=None)): # ── Detect anomaly on the FULL order list ─────────────────────────── anomaly, reason = _detect_step_anomaly(all_orders) - if not anomaly and not pending: - # Everything delivered, steps clean — nothing to do + if not anomaly: + # Steps are already clean and sequential — nothing to fix. Don't + # touch a healthy in-progress route just because it has pending + # stops; only an actual anomaly should trigger resequencing. results.append({ "rider_id": rider_id, "rider_name": rider_name, "anomaly_detected": False, - "anomaly_type": "ok", + "anomaly_type": reason, "delivered_count": len(delivered), - "pending_count": 0, + "pending_count": len(pending), "resequenced": False, "orders": all_orders, }) @@ -370,24 +374,53 @@ async def reconcile_steps(body: Any = Body(default=None)): ) return o + # ── Renumber: delivered keep positions 1..N, pending continue after ─ + # Sort delivered by their existing step so the sequence is stable. + # Done BEFORE re-optimizing pending so the rider's last drop can be + # used as the start point for resequencing (see below). + delivered_sorted = sorted(delivered, key=_step_val) + + # Reassign delivered steps cleanly (fills any gap in delivered portion) + for i, o in enumerate(delivered_sorted, start=1): + o["step"] = i + max_delivered_step = len(delivered_sorted) + + # ── Rider's current position ──────────────────────────────────────── + # A mid-route rider is at their last delivered drop, not back at the + # kitchen. Feed that in as the optimizer's start point so pending + # stops get resequenced from where the rider actually is; falls back + # to None (-> pickup/kitchen coords) only if nothing has been + # delivered yet, which is correct for a route that hasn't started. + start_coords = None + if delivered_sorted: + try: + slat = float(delivered_sorted[-1].get("deliverylat") or 0) + slon = float(delivered_sorted[-1].get("deliverylong") or 0) + if slat != 0 or slon != 0: + start_coords = (slat, slon) + except (ValueError, TypeError): + pass + # ── Re-optimise pending orders ────────────────────────────────────── # Run the same route optimizer used at assignment time so step order # matches the greedy + 2-opt algorithm (now with crossings eliminated). # Fallback: sort by existing step value so at least the sequence is # deterministic when the optimizer cannot run. resequenced_pending: list[dict] = sorted(pending, key=_step_val) + used_optimizer = False if pending: try: _opt = RouteOptimizer() _optimized = await _opt.optimize_provider_payload( [_norm_coords(o) for o in pending], - start_coords=None, + start_coords=start_coords, ) if _optimized: # Optimizer returns list in route order (index 0 = first stop). # Use it; otherwise keep the sorted fallback. resequenced_pending = _optimized + used_optimizer = True else: logger.warning( f"[Reconcile] Optimizer returned empty result for rider " @@ -399,14 +432,54 @@ async def reconcile_steps(body: Any = Body(default=None)): f"(non-fatal, keeping step-sorted fallback): {_oe}" ) - # ── Renumber: delivered keep positions 1..N, pending continue after ─ - # Sort delivered by their existing step so the sequence is stable. - delivered_sorted = sorted(delivered, key=_step_val) + # The optimizer computes cumulativekms/cumulative_eta relative to + # start_coords (the rider's last drop), i.e. starting back at 0 — + # but those fields mean "total since kitchen departure" and the + # delivered orders ahead of them keep their original kitchen-baseline + # values untouched. Offset the pending values so the full list stays + # continuous instead of jumping back down after the delivered stops. + if used_optimizer and delivered_sorted: + try: + base_km = float(delivered_sorted[-1].get("cumulativekms") or 0) + except (ValueError, TypeError): + base_km = 0.0 + try: + base_eta = float(delivered_sorted[-1].get("cumulative_eta") or 0) + except (ValueError, TypeError): + base_eta = 0.0 + if base_km or base_eta: + for o in resequenced_pending: + try: + o["cumulativekms"] = int(round(float(o.get("cumulativekms") or 0) + base_km)) + except (ValueError, TypeError): + pass + try: + o["cumulative_eta"] = str(int(round(float(o.get("cumulative_eta") or 0) + base_eta))) + except (ValueError, TypeError): + pass - # Reassign delivered steps cleanly (fills any gap in delivered portion) - for i, o in enumerate(delivered_sorted, start=1): - o["step"] = i - max_delivered_step = len(delivered_sorted) + # ── Recompute expected delivery time for pending orders ───────────── + # A resequenced order's cumulative_eta just changed (offset above, or + # a fresh optimizer value) — any expectedDeliveryTime computed at the + # original assignment is now stale, same math as /riderassign uses. + for o in resequenced_pending: + pickup_slot_str = ( + o.get("pickupSlot") or o.get("pickupslot") + or o.get("pickup_slot") or o.get("pickuptime") + ) + if not pickup_slot_str or not o.get("cumulative_eta"): + continue + try: + pickup_time = parse_date(str(pickup_slot_str)) + eta_mins = int(float(o["cumulative_eta"])) + expected_time = pickup_time + timedelta(minutes=eta_mins) + o["expectedDeliveryTime"] = expected_time.strftime("%Y-%m-%d %I:%M %p") + o["transitMinutes"] = eta_mins + except Exception as _te: + logger.warning( + f"[Reconcile] Could not recompute expectedDeliveryTime for " + f"rider {rider_id} order {o.get('orderid')}: {_te}" + ) # Pending steps start immediately after the last delivered step. # resequenced_pending is already in route order (optimizer output or diff --git a/app/services/routing/__pycache__/route_optimizer.cpython-312.pyc b/app/services/routing/__pycache__/route_optimizer.cpython-312.pyc deleted file mode 100644 index 5353906..0000000 Binary files a/app/services/routing/__pycache__/route_optimizer.cpython-312.pyc and /dev/null differ diff --git a/app/services/routing/route_optimizer.py b/app/services/routing/route_optimizer.py index 1223b54..e3f6872 100644 --- a/app/services/routing/route_optimizer.py +++ b/app/services/routing/route_optimizer.py @@ -1044,13 +1044,17 @@ class RouteOptimizer: order["previouskms"] = int(round(step_dist)) # Bug fix: was hardcoded 0 for step 1 order["cumulativekms"] = int(round(cumulative_dist)) - # actualkms = direct pickup-to-door distance (for billing) - plat, plon = start_lat, start_lon + # actualkms = direct pickup-to-door distance (for billing). + # Always the order's own kitchen, never the routing depot: when + # start_coords is the rider's current position (e.g. reconcile + # mid-route) rather than the kitchen, start_lat/start_lon no + # longer equals the pickup location. + plat, plon = ( + _to_float(order.get("pickuplat")), + _to_float(order.get("pickuplon") or order.get("pickuplong")), + ) if plat == 0: - plat, plon = ( - _to_float(order.get("pickuplat")), - _to_float(order.get("pickuplon") or order.get("pickuplong")), - ) + plat, plon = start_lat, start_lon dlat, dlon = locations[solver_idx] true_dist = self.haversine_distance(plat, plon, dlat, dlon) * 1.3 diff --git a/ml_data/ml_store.db b/ml_data/ml_store.db deleted file mode 100644 index 099b0c1..0000000 Binary files a/ml_data/ml_store.db and /dev/null differ