Init Git Prod Mars 2026

This commit is contained in:
Oleg Barykin committed 2026-03-05 10:25:39 +01:00
commit d81efe93b1
362 files changed
+86627

No files matched your search

+370
View File
@@ -0,0 +1,370 @@
<?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>