371 lines
17 KiB
PHP
371 lines
17 KiB
PHP
<?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>
|