PATCH INSTRUCTIES VOOR: api/booking/update_status.php ====================================================== Twee wijzigingen in dit bestand: PATCH 3A: Backfill chauffeur_aangemeld status wanneer planner die stap overslaat en direct naar aan_dock of lossen_afgerond gaat. Zo heeft de opt-in mail altijd een tijdstempel voor "aangemeld" (matcht aan_dock tijd). PATCH 3B: Pull-BACKWARD voor late chauffeurs (inbound/nonfood). Outbound blijft ongemoeid — die loopt via transportplanning. ================================================================ PATCH 3A: Backfill chauffeur_aangemeld ================================================================ Zoek DEZE block (rond regel 361-387): $ins=$pdo->prepare('INSERT INTO dock_booking_status (dock_booking_id,status) VALUES (:id,:s)'); $ins->execute([':id'=>$dockBookingId, ':s'=>$status]); // Step A (metrics only): capture timestamps for unloading speed if ($status === 'aan_dock') { dp_mark_at_dock($pdo, $dockBookingId, null); } // BACKFILL: als de planner direct naar lossen_afgerond springt zonder via // aan_dock, dan zetten we alsnog een at_dock_at. Zo blijft de metriek // "hoe lang stond deze wagen aan het dock" betrouwbaar bestaan voor // toekomstige learning. We gebruiken een benadering (nu - verwachte duur) // voor aan_dock_at als die nog niet gezet was. if ($status === 'lossen_afgerond' && in_array('aan_dock', $skippedStatuses, true)) { try { $stChk = $pdo->prepare("SELECT at_dock_at FROM dock_bookings WHERE id = :id"); $stChk->execute([':id' => $dockBookingId]); $existingAtDock = $stChk->fetchColumn(); if (!$existingAtDock) { // Markeer aan_dock NU — de exacte tijd weten we niet, maar dit is // beter dan NULL (anders kan de metriek helemaal niet worden berekend). dp_mark_at_dock($pdo, $dockBookingId, null); } } catch (Throwable $e) { /* best effort, geen blokker */ } } if ($status === 'lossen_afgerond') { dp_mark_unloading_done_and_record($pdo, $dockBookingId, null); } VERVANG DOOR: // PATCH-3A: Backfill overgeslagen statussen VOOR de huidige insert, zodat // MIN(updated_at) voor "chauffeur_aangemeld" altijd gevuld is in de mail- // history. De planner kan direct naar aan_dock of lossen_afgerond springen; // we vullen dan alle tussenliggende statussen als rijen in dock_booking_status // met het huidige tijdstip. Dit zorgt dat de opt-in mail ("Chauffeur gelost") // niet "—" toont bij "Chauffeur aangemeld". if (!empty($skippedStatuses)) { try { $insSkip = $pdo->prepare( 'INSERT INTO dock_booking_status (dock_booking_id, status) VALUES (:id, :s)' ); foreach ($skippedStatuses as $skippedSt) { $insSkip->execute([':id' => $dockBookingId, ':s' => $skippedSt]); } } catch (Throwable $e) { error_log('[PATCH-3A backfill status] ' . $e->getMessage()); } } $ins=$pdo->prepare('INSERT INTO dock_booking_status (dock_booking_id,status) VALUES (:id,:s)'); $ins->execute([':id'=>$dockBookingId, ':s'=>$status]); // Step A (metrics only): capture timestamps for unloading speed if ($status === 'aan_dock') { dp_mark_at_dock($pdo, $dockBookingId, null); } // BACKFILL: als de planner direct naar lossen_afgerond springt zonder via // aan_dock, dan zetten we alsnog een at_dock_at. Zo blijft de metriek // "hoe lang stond deze wagen aan het dock" betrouwbaar bestaan voor // toekomstige learning. We gebruiken een benadering (nu - verwachte duur) // voor aan_dock_at als die nog niet gezet was. if ($status === 'lossen_afgerond' && in_array('aan_dock', $skippedStatuses, true)) { try { $stChk = $pdo->prepare("SELECT at_dock_at FROM dock_bookings WHERE id = :id"); $stChk->execute([':id' => $dockBookingId]); $existingAtDock = $stChk->fetchColumn(); if (!$existingAtDock) { // Markeer aan_dock NU — de exacte tijd weten we niet, maar dit is // beter dan NULL (anders kan de metriek helemaal niet worden berekend). dp_mark_at_dock($pdo, $dockBookingId, null); } } catch (Throwable $e) { /* best effort, geen blokker */ } } if ($status === 'lossen_afgerond') { dp_mark_unloading_done_and_record($pdo, $dockBookingId, null); } ================================================================ PATCH 3B: Pull-BACKWARD voor late chauffeurs (inbound/nonfood) ================================================================ Zoek DEZE block in de functie dp_try_pull_forward_on_checkin() (rond regel 164-173): // If already planned, only pull-forward earlier than current planned time. if ($curDockId > 0 && $curIdx !== null) { if ($arrivalIdx >= $curIdx) { return ['attempted'=>true,'moved'=>false,'reason'=>'not_earlier']; } $maxStartIdx = min($maxStartIdx, $curIdx - 1); if ($maxStartIdx < $arrivalIdx) { return ['attempted'=>true,'moved'=>false,'reason'=>'no_earlier_space']; } } VERVANG DOOR: // PATCH-3B: Pull-FORWARD of pull-BACKWARD bepalen aan de hand van aankomst // t.o.v. geplande tijd. // // Pull-BACKWARD regels (chauffeur is te laat): // - Alleen voor mode 'inbound' of 'nonfood'. Outbound wordt geheel via // transportplanning beheerd en wordt hier NIET verplaatst. // - <= 5 min te laat: tolerantie, geen verplaatsing. // - 6–15 min te laat: schuif naar eerstvolgende 15-min grens na aankomst. // - > 15 min te laat: schuif naar eerstvolgende 30-min grens na aankomst. // - Feature-flag: app_setting 'feature_pull_backward_enabled' (default true). // // Alle thresholds zijn zo gekozen dat de planning op hele slots blijft // vallen (30-min grid). 15-min grenzen zijn toegestaan omdat de // slot-overlap-checker alleen kijkt naar start + duration_min, niet naar // een vast grid. if ($curDockId > 0 && $curIdx !== null) { $pullMode = ''; if ($arrivalIdx < $curIdx) { // Chauffeur is (te) vroeg → pull-forward pad zoals voorheen. $pullMode = 'forward'; $maxStartIdx = min($maxStartIdx, $curIdx - 1); if ($maxStartIdx < $arrivalIdx) { return ['attempted'=>true,'moved'=>false,'reason'=>'no_earlier_space']; } } elseif ($arrivalIdx === $curIdx) { return ['attempted'=>true,'moved'=>false,'reason'=>'on_time']; } else { // Chauffeur is te laat → pull-backward overwegen. if ($mode === 'outbound') { return ['attempted'=>true,'moved'=>false,'reason'=>'outbound_managed_elsewhere']; } if (!dp_app_setting_get_bool($pdo, 'feature_pull_backward_enabled', true)) { return ['attempted'=>true,'moved'=>false,'reason'=>'pull_backward_disabled']; } // Berekende exacte aankomsttijd i.p.v. afgeronde arrivalIdx (die op // 30-min boundary zit). Het verschil met de geboekte starttijd // bepaalt de step. $bookedDt = null; try { $bookedDt = new DateTimeImmutable( $bookingRow['date'] . ' ' . $bookingRow['time'] ); } catch (Throwable $e) {} if (!$bookedDt) { return ['attempted'=>true,'moved'=>false,'reason'=>'bad_booking_time']; } $lateSec = $now->getTimestamp() - $bookedDt->getTimestamp(); $lateMin = (int)floor($lateSec / 60); if ($lateMin <= 5) { return ['attempted'=>true,'moved'=>false,'reason'=>'within_tolerance']; } $step = ($lateMin <= 15) ? 15 : 30; // Rond aankomsttijd naar boven af op step. $ts = $now->getTimestamp(); $stepSec = $step * 60; $ceilTs = (int)(ceil($ts / $stepSec) * $stepSec); $arrivalBackward = (new DateTimeImmutable('@' . $ceilTs)) ->setTimezone(new DateTimeZone(date_default_timezone_get() ?: 'Europe/Amsterdam')); // Her-bereken arrivalIdx en rekening ermee dat de ETA-index-helper op // 30-min grid werkt. We nemen de floor-index van de nieuwe doeltijd. $arrivalIdx = dp_eta_toIndex_floor($arrivalBackward->format('H:i:s')); $arrivalRounded = $arrivalBackward; $pullMode = 'backward'; // Log pull-backward intent (wordt pas definitief als find_earliest lukt). $payloadBackward = [ 'action' => 'PULL_BACKWARD_ATTEMPT', 'trigger' => 'STATUS_CHAUFFEUR_AANGEMELD_LATE', 'late_minutes' => $lateMin, 'step' => $step, 'from_time' => $curTime, 'to_time_min' => $arrivalBackward->format('H:i:s'), ]; dp_explain_log_write($pdo, $bookingId, 'PULL_BACKWARD_ATTEMPT', 'PULL_BACKWARD_ATTEMPT', 'Chauffeur te laat — probeer booking naar achter te schuiven', $payloadBackward); } }