N jours), prévu passé (>N jours), (option) terminé (>N jours), orphelins, doublons exacts * - DRY-RUN par défaut ; purge LIVE via POST + CSRF * - Reauth admin (10 min) * - Journal d’audit (admin_audit) */ if (($_GET['page'] ?? '') !== 'cleanup_rdv') return; require_once __DIR__ . '/../../public/includes/init_admin.php'; $roles = (array)($_SESSION['role'] ?? []); if (!in_array('admin', $roles, true)) { echo '
' . htmlspecialchars((string)($translations['clean_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8') . '
'; return; } if (!isset($pdo) || !($pdo instanceof PDO)) { echo '
' . htmlspecialchars((string)($translations['clean_db_unavailable'] ?? 'DB indisponible.'), ENT_QUOTES, 'UTF-8') . '
'; return; } try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Throwable) {} require_once __DIR__.'/_lib_audit.php'; // ---- Reauth 10 min $mustReauth = function(int $ttl=600){ $t=(int)($_SESSION['admin_reauth_at']??0); return ($t===0 || (time()-$t)>$ttl); }; if ($mustReauth()) { $qs = http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI'] ?? 'index.php?page=maintenance')]); header('Location:?'.$qs); exit; } // ---- Helpers (closures, pas de fonctions globales) $e = static fn($s)=>htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token']=bin2hex(random_bytes(32)); $CSRF = $_SESSION['csrf_token']; // ---- Paramètres UI $days = max(0, (int)($_REQUEST['days'] ?? 30)); $mode = ($_REQUEST['mode'] ?? 'dry') === 'live' ? 'live' : 'dry'; $dry = ($mode !== 'live'); $opt_cancelled = isset($_REQUEST['cancelled']) ? ($_REQUEST['cancelled']==='1') : true; // "annulé" $opt_past_prevu = isset($_REQUEST['past_prevu']) ? ($_REQUEST['past_prevu']==='1') : true; // "prévu" passé $opt_terminated = isset($_REQUEST['terminated']) ? ($_REQUEST['terminated']==='1') : false; // "terminé" (option) $opt_orphans = isset($_REQUEST['orphans']) ? ($_REQUEST['orphans']==='1') : true; // orphelins $opt_duplicates = isset($_REQUEST['duplicates']) ? ($_REQUEST['duplicates']==='1') : true; // doublons exacts // ---- Cutoff (on évite INTERVAL ? DAY ; on calcule en PHP) $cutoff = date('Y-m-d H:i:s', time() - $days*86400); // ---- Sanity: table & colonnes $tbl = 'rendez_vous'; $hasTable = (bool)$pdo->query("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'rendez_vous'")->fetchColumn(); if (!$hasTable) { $tpl = (string)($translations['clean_table_missing'] ?? 'Table {T} introuvable.'); echo '
'.str_replace('{T}', $e('rendez_vous'), $tpl).'
'; return; } // Détection tables référentielles (souples : pluriel/singulier) $guessTable = static function(PDO $pdo, array $candidates): ?string { foreach ($candidates as $t) { $st = $pdo->prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?"); $st->execute([$t]); if ($st->fetchColumn()) return $t; } return null; }; $tblPraticiens = $guessTable($pdo, ['praticiens','praticien','praticien_profil','praticien_profile']); $tblClients = $guessTable($pdo, ['clients','client']); // ---- Collecte candidats par catégorie $cats = []; // ['key'=>['title'=>..., 'ids'=>[], 'rows'=>[...]]] $pushCat = static function(array &$cats, string $key, string $title, array $rows) { $cats[$key] = ['title'=>$title, 'rows'=>$rows, 'ids'=>array_map(fn($r)=>(int)$r['id'],$rows)]; }; // 1) "annulé" avant $cutoff if ($opt_cancelled) { $st = $pdo->prepare("SELECT id, statut, date_rdv FROM `{$tbl}` WHERE statut = 'annulé' AND date_rdv < :cut ORDER BY date_rdv ASC LIMIT 5000"); $st->execute([':cut'=>$cutoff]); $rows = $st->fetchAll(PDO::FETCH_ASSOC); $tpl = (string)($translations['clean_cat_cancelled_title'] ?? 'RDV annulés avant {D} j'); $pushCat($cats, 'cancelled', str_replace('{D}', (string)$days, $tpl), $rows); } // 2) "prévu" passé avant $cutoff (no-show) if ($opt_past_prevu) { $st = $pdo->prepare("SELECT id, statut, date_rdv FROM `{$tbl}` WHERE statut = 'prévu' AND date_rdv < :cut ORDER BY date_rdv ASC LIMIT 5000"); $st->execute([':cut'=>$cutoff]); $rows = $st->fetchAll(PDO::FETCH_ASSOC); $tpl = (string)($translations['clean_cat_past_prevu_title'] ?? 'RDV prévus mais passés (> {D} j)'); $pushCat($cats, 'past_prevu', str_replace('{D}', (string)$days, $tpl), $rows); } // 3) (option) "terminé" avant $cutoff if ($opt_terminated) { $st = $pdo->prepare("SELECT id, statut, date_rdv FROM `{$tbl}` WHERE statut = 'terminé' AND date_rdv < :cut ORDER BY date_rdv ASC LIMIT 5000"); $st->execute([':cut'=>$cutoff]); $rows = $st->fetchAll(PDO::FETCH_ASSOC); $tpl = (string)($translations['clean_cat_terminated_title'] ?? 'RDV terminés (> {D} j)'); $pushCat($cats, 'terminated', str_replace('{D}', (string)$days, $tpl), $rows); } // 4) Orphelins (praticien/client inexistants) if ($opt_orphans && $tblPraticiens && $tblClients) { $sql = "SELECT rv.id, rv.statut, rv.date_rdv FROM `{$tbl}` rv LEFT JOIN `{$tblPraticiens}` p ON p.id = rv.praticien_id LEFT JOIN `{$tblClients}` c ON c.id = rv.client_id WHERE (p.id IS NULL OR c.id IS NULL) ORDER BY rv.date_rdv ASC LIMIT 5000"; $rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); $pushCat($cats, 'orphans', (string)($translations['clean_cat_orphans_title'] ?? 'RDV orphelins (praticien/client manquants)'), $rows); } elseif ($opt_orphans) { $rows = []; $pushCat($cats, 'orphans', (string)($translations['clean_cat_orphans_tables_missing'] ?? 'RDV orphelins (tables praticiens/clients introuvables)'), $rows); } // 5) Doublons exacts (même praticien_id, client_id, date_rdv). On garde le MIN(id), supprime le reste. if ($opt_duplicates) { // On limite aux RDV plus vieux que cutoff pour rester conservateurs $sql = " SELECT rv2.id, rv2.statut, rv2.date_rdv FROM `{$tbl}` rv2 JOIN ( SELECT praticien_id, client_id, date_rdv, MIN(id) keep_id, COUNT(*) cnt FROM `{$tbl}` WHERE date_rdv < :cut GROUP BY praticien_id, client_id, date_rdv HAVING cnt > 1 ) d ON d.praticien_id = rv2.praticien_id AND d.client_id = rv2.client_id AND d.date_rdv = rv2.date_rdv WHERE rv2.id <> d.keep_id AND rv2.statut <> 'confirmé' ORDER BY rv2.date_rdv ASC LIMIT 5000"; $st = $pdo->prepare($sql); $st->execute([':cut'=>$cutoff]); $rows = $st->fetchAll(PDO::FETCH_ASSOC); $pushCat($cats, 'duplicates', (string)($translations['clean_cat_duplicates_title'] ?? 'Doublons exacts (on garde l’ID le plus bas)'), $rows); } // ---- Union de tous les IDs à supprimer $allIds = []; foreach ($cats as $k=>$c) { foreach ($c['ids'] as $id) { $allIds[$id] = true; } } $allIds = array_keys($allIds); // ---- Exécution LIVE (DELETE) si demandé $deleted = 0; $err = null; if (!$dry && $_SERVER['REQUEST_METHOD']==='POST' && ($_POST['action'] ?? '')==='purge') { if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) { $err = (string)($translations['clean_csrf_invalid'] ?? 'Token CSRF invalide.'); } else { if ($allIds) { // Suppression en batch $pdo->beginTransaction(); try { // Découpage par tranches de 1000 $chunks = array_chunk($allIds, 1000); foreach ($chunks as $batch) { $placeholders = implode(',', array_fill(0, count($batch), '?')); $st = $pdo->prepare("DELETE FROM `{$tbl}` WHERE id IN ($placeholders)"); $st->execute(array_map('intval', $batch)); $deleted += $st->rowCount(); } $pdo->commit(); AdminAudit::log($pdo, [ 'admin_id' => (int)($_SESSION['user_id'] ?? 0), 'action' => 'rdv_cleanup', 'scope' => 'maintenance', 'target_table' => $tbl, 'meta' => [ 'deleted' => $deleted, 'days' => $days, 'cancelled' => $opt_cancelled, 'past_prevu' => $opt_past_prevu, 'terminated' => $opt_terminated, 'orphans' => $opt_orphans, 'duplicates' => $opt_duplicates ] ]); } catch (Throwable $ex) { $pdo->rollBack(); $err = (string)($translations['clean_delete_error_prefix'] ?? 'Erreur suppression: ') . $e($ex->getMessage()); } } } } // ---- Rendu $totalByCat = []; foreach ($cats as $k=>$c){ $totalByCat[$k]=count($c['ids']); } $totalAll = count($allIds); ?>

{COL})'); echo str_replace(['{D}','{COL}'], [$e((string)(int)$days), $e('date_rdv')], $tpl); ?>
0): ?>
$cat): $rows = array_slice($cat['rows'], 0, 150); ?>
—