Files
maria_uslugi/admin/tools/cleanup_rdv.php
T
2026-03-05 10:25:39 +01:00

371 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* /admin/tools/cleanup_rdv.php
* Nettoyage des rendez-vous (table `rendez_vous`)
* - Cibles: annulé (>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 '<div class="container my-4"><div class="alert alert-danger">'
. htmlspecialchars((string)($translations['clean_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
. '</div></div>';
return;
}
if (!isset($pdo) || !($pdo instanceof PDO)) {
echo '<div class="container my-4"><div class="alert alert-danger">'
. htmlspecialchars((string)($translations['clean_db_unavailable'] ?? 'DB indisponible.'), ENT_QUOTES, 'UTF-8')
. '</div></div>';
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 <code>{T}</code> introuvable.');
echo '<div class="container my-4"><div class="alert alert-danger">'.str_replace('{T}', $e('rendez_vous'), $tpl).'</div></div>';
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);
?>
<div class="shadow p-4 rounded bg-light">
<div class="container my-4">
<div class="d-flex align-items-center gap-2 mb-3">
<h3 class="m-0"><?= $e($translations['clean_title'] ?? 'Nettoyage des rendez-vous') ?></h3>
<span class="badge text-bg-<?= $dry?'secondary':'danger' ?>">
<?= $dry ? $e($translations['clean_mode_dry'] ?? 'DRY-RUN (simulation)') : $e($translations['clean_mode_live'] ?? 'LIVE (suppression activée)') ?>
</span>
<span class="badge text-bg-info">
<?php
$tpl = (string)($translations['clean_age_badge'] ?? 'Âge ≥ {D} j (référence: <code>{COL}</code>)');
echo str_replace(['{D}','{COL}'], [$e((string)(int)$days), $e('date_rdv')], $tpl);
?>
</span>
</div>
<?php if($err): ?><div class="alert alert-danger"><?= $e($err) ?></div><?php endif; ?>
<?php if(!$err && !$dry && $deleted>0): ?>
<div class="alert alert-success">
<?php
$tpl = (string)($translations['clean_deleted_ok'] ?? '{N} rendez-vous supprimés.');
echo $e(str_replace('{N}', (string)(int)$deleted, $tpl));
?>
</div>
<?php endif; ?>
<div class="card mb-3">
<div class="card-body">
<form method="post" class="row g-3">
<input type="hidden" name="csrf" value="<?= $e($CSRF) ?>">
<div class="col-12 col-md-3">
<label class="form-label"><?= $e($translations['clean_age_label'] ?? 'Âge minimum') ?></label>
<div class="input-group">
<input class="form-control" type="number" min="0" name="days" value="<?= (int)$days ?>">
<span class="input-group-text"><?= $e($translations['clean_days_unit'] ?? 'jours') ?></span>
</div>
</div>
<div class="col-12 col-md-9">
<label class="form-label"><?= $e($translations['clean_categories_label'] ?? 'Catégories ciblées') ?></label>
<div class="d-flex flex-wrap gap-3">
<label class="form-check">
<input class="form-check-input" type="checkbox" name="cancelled" value="1" <?= $opt_cancelled?'checked':'' ?>>
<span class="form-check-label"><?= $e($translations['clean_opt_cancelled'] ?? 'Annulés') ?></span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="past_prevu" value="1" <?= $opt_past_prevu?'checked':'' ?>>
<span class="form-check-label"><?= $e($translations['clean_opt_past_prevu'] ?? 'Prévu (dépassés)') ?></span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="terminated" value="1" <?= $opt_terminated?'checked':'' ?>>
<span class="form-check-label"><?= $e($translations['clean_opt_terminated'] ?? 'Terminés') ?></span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="orphans" value="1" <?= $opt_orphans?'checked':'' ?>>
<span class="form-check-label"><?= $e($translations['clean_opt_orphans'] ?? 'Orphelins') ?></span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="duplicates" value="1" <?= $opt_duplicates?'checked':'' ?>>
<span class="form-check-label"><?= $e($translations['clean_opt_duplicates'] ?? 'Doublons exacts') ?></span>
</label>
</div>
</div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-outline-secondary"
name="action" value="scan"
formaction="?page=cleanup_rdv&mode=dry">
<?= $e($translations['clean_btn_scan'] ?? 'Scanner') ?>
</button>
<button class="btn btn-danger"
name="action" value="purge"
formaction="?page=cleanup_rdv&mode=live"
onclick="return confirm('<?= $e($translations['clean_confirm_purge'] ?? 'Confirmer la suppression des RDV listés ?') ?>')">
<?= $e($translations['clean_btn_purge'] ?? 'Purger') ?>
</button>
<div class="ms-auto d-flex align-items-center small text-muted">
<?php
$tpl = (string)($translations['clean_table_label'] ?? 'Table:');
echo $e($tpl);
?>
<code class="ms-1"><?= $e($tbl) ?></code>
</div>
</div>
</form>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header"><?= $e($translations['clean_summary_title'] ?? 'Résumé') ?></div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['clean_sum_total'] ?? 'Total candidats') ?>
<span class="badge text-bg-secondary"><?= (int)$totalAll ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['clean_sum_cancelled'] ?? 'Annulés') ?>
<span class="badge text-bg-secondary"><?= (int)($totalByCat['cancelled'] ?? 0) ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['clean_sum_past_prevu'] ?? 'Prévu (dépassés)') ?>
<span class="badge text-bg-secondary"><?= (int)($totalByCat['past_prevu'] ?? 0) ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['clean_sum_terminated'] ?? 'Terminés') ?>
<span class="badge text-bg-secondary"><?= (int)($totalByCat['terminated'] ?? 0) ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['clean_sum_orphans'] ?? 'Orphelins') ?>
<span class="badge text-bg-secondary"><?= (int)($totalByCat['orphans'] ?? 0) ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['clean_sum_duplicates'] ?? 'Doublons') ?>
<span class="badge text-bg-secondary"><?= (int)($totalByCat['duplicates'] ?? 0) ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['clean_sum_mode'] ?? 'Mode') ?>
<span class="badge text-bg-<?= $dry?'secondary':'danger' ?>"><?= $e($dry ? ($translations['clean_mode_short_dry'] ?? 'DRY-RUN') : ($translations['clean_mode_short_live'] ?? 'LIVE')) ?></span>
</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-lg-8">
<div class="card h-100">
<div class="card-header"><?= $e($translations['clean_preview_title'] ?? 'Aperçu des candidats (max 150 par catégorie)') ?></div>
<div class="card-body p-0">
<?php foreach ($cats as $key=>$cat):
$rows = array_slice($cat['rows'], 0, 150);
?>
<div class="p-2 border-bottom fw-semibold"><?= $e($cat['title']) ?> — <?= count($cat['rows']) ?> <?= $e($translations['clean_found'] ?? 'trouvés') ?></div>
<?php if (!$rows): ?>
<div class="p-3 small text-muted"><?= $e($translations['clean_none_for_criteria'] ?? 'Aucun avec ces critères.') ?></div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr>
<th><?= $e($translations['clean_th_id'] ?? 'ID') ?></th>
<th><?= $e($translations['clean_th_status'] ?? 'Statut') ?></th>
<th><?= $e($translations['clean_th_date_rdv'] ?? 'Date RDV') ?></th>
</tr></thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td><?= (int)$r['id'] ?></td>
<td><span class="badge text-bg-secondary"><?= $e($r['statut'] ?? $r['s'] ?? '') ?></span></td>
<td><?= $e($r['date_rdv'] ?? $r['d'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
</div>