From 1035f57b926dc03a15b2d0db3d552203c0d26701 Mon Sep 17 00:00:00 2001 From: solocla Date: Thu, 24 Sep 2026 10:57:03 +0200 Subject: [PATCH] cron report --- cron/class_report.php | 327 +++++++++++++++++++++++++++++++++++ cron/preview/classe_314.html | 1 + 2 files changed, 328 insertions(+) create mode 100644 cron/class_report.php create mode 100644 cron/preview/classe_314.html diff --git a/cron/class_report.php b/cron/class_report.php new file mode 100644 index 0000000..89cc41f --- /dev/null +++ b/cron/class_report.php @@ -0,0 +1,327 @@ + classi di oggi + * php cron/class_report.php 2026-09-25 -> classi di una data specifica + * php cron/class_report.php --dry-run -> NON invia, salva le anteprime HTML in cron/preview/ + */ + +use PHPMailer\PHPMailer\PHPMailer; +use PHPMailer\PHPMailer\Exception as MailException; + +if (PHP_SAPI !== 'cli') { + http_response_code(403); + exit('Accesso consentito solo da CLI'); +} + +date_default_timezone_set('Europe/Rome'); + +$root = dirname(__DIR__); +chdir($root); +require_once $root . '/public/class/db-functions.php'; // include anche vendor/autoload.php + +$pdo = DBHandlerSelect::getInstance()->getConnection(); // carica anche il .env + +/* ---------------------------------------------------------------- helper */ + +function cfg(string $key, $default = null) +{ + $v = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); + return ($v === false || $v === null || $v === '') ? $default : $v; +} + +function h($v): string +{ + return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +} + +function logmsg(string $m): void +{ + echo '[' . date('Y-m-d H:i:s') . '] ' . $m . PHP_EOL; +} + +function chip(string $label, string $bg, string $fg): string +{ + return '' . h($label) . ''; +} + +function fullName(array $p): string +{ + $n = trim(($p['first_name'] ?? '') . ' ' . ($p['last_name'] ?? '')); + return $n !== '' ? $n : 'Utente #' . (int) ($p['iduser'] ?? 0); +} + +function italianDate(DateTime $d): string +{ + $days = ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato']; + $months = ['', 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre']; + return $days[(int) $d->format('w')] . ' ' . (int) $d->format('j') . ' ' . $months[(int) $d->format('n')] . ' ' . $d->format('Y'); +} + +/* ----------------------------------------------------------- argomenti */ + +$dryRun = in_array('--dry-run', $argv, true); +$day = date('Y-m-d'); +foreach (array_slice($argv, 1) as $a) { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $a)) { + $day = $a; + } +} + +$recipients = array_values(array_filter(array_map('trim', explode(',', (string) cfg('TEACHER_EMAIL', ''))))); +if (!$recipients && !$dryRun) { + logmsg('ERRORE: TEACHER_EMAIL non impostato nel .env'); + exit(1); +} + +$location = cfg('CLASS_LOCATION', 'via Valassina 62/B Seregno - Sala Contesto Yoga'); + +/* ------------------------------------------------------ classi del giorno */ + +$stmt = $pdo->prepare( + "SELECT ss.idserviceschedule, ss.dateschedule, s.servicename, s.colorclass, s.maxcapacity + FROM serviceschedule ss + LEFT JOIN service s ON ss.idservice = s.idservice + WHERE ss.dateschedule >= :d1 AND ss.dateschedule < :d2 + ORDER BY ss.dateschedule" +); +$stmt->execute([ + ':d1' => $day . ' 00:00:00', + ':d2' => date('Y-m-d', strtotime($day . ' +1 day')) . ' 00:00:00', +]); +$classes = $stmt->fetchAll(); + +if (!$classes) { + logmsg("Nessuna classe il $day. Nessuna mail inviata."); + exit(0); +} + +$ids = array_map(fn($c) => (int) $c['idserviceschedule'], $classes); +$ph = implode(',', array_fill(0, count($ids), '?')); +$stmtB = $pdo->prepare( + "SELECT bc.idbookingclass, bc.idserviceschedule, bc.lostlesson, bc.status, + u.id AS iduser, u.first_name, u.last_name, u.email + FROM bookingclass bc + LEFT JOIN auth_users u ON bc.iduser = u.id + WHERE bc.idserviceschedule IN ($ph) + AND bc.status IN ('booked', 'pending', 'waiting') + ORDER BY bc.created_at, u.last_name, u.first_name" +); +$stmtB->execute($ids); + +$bookingsBySchedule = []; +foreach ($stmtB->fetchAll() as $b) { + $bookingsBySchedule[(int) $b['idserviceschedule']][] = $b; +} + +/* ------------------------------------------------------------ template */ + +function personRows(array $list, string $type): string +{ + if (!$list) { + return 'Nessun partecipante'; + } + $numColor = ['booked' => '#1ebf73', 'pending' => '#8a6d00', 'waiting' => '#d1698a'][$type]; + $html = ''; + $i = 1; + foreach ($list as $p) { + $isLost = ($type === 'booked' && ($p['lostlesson'] ?? '') === 'Y'); + if ($type === 'booked') { + $chipHtml = $isLost ? chip('Persa', '#1f2937', '#ffffff') : chip('Confermata', '#e3f2ec', '#1a7f52'); + } elseif ($type === 'pending') { + $chipHtml = chip('In attesa', '#fff4d6', '#8a6d00'); + } else { + $chipHtml = chip('In coda', '#fbeef3', '#d1698a'); + } + $nameColor = $isLost ? '#9ca3af' : '#1f2937'; + $email = !empty($p['email']) ? '
' . h($p['email']) . '' : ''; + + $html .= '' + . '' + . '' + . '' + . '' + . '
' . $i . ($type === 'waiting' ? '°' : '.') . '' . h(fullName($p)) . $email . '' . $chipHtml . '
'; + $i++; + } + return $html; +} + +function section(string $title, string $color, string $rows): string +{ + return '' + . '
' . $title . '
' + . '' . $rows . '
' + . ''; +} + +function statBox(string $value, string $label, string $color): string +{ + return '' + . '
' . $value . '
' + . '
' . $label . '
' + . ''; +} + +function renderEmail(array $cls, array $booked, array $pending, array $waiting, string $location): string +{ + $d = new DateTime($cls['dateschedule']); + $accent = $cls['colorclass'] ?: '#1ebf73'; + $max = (int) $cls['maxcapacity']; + $nBooked = count($booked); + $nLost = count(array_filter($booked, fn($b) => ($b['lostlesson'] ?? '') === 'Y')); + $free = $max > 0 ? max(0, $max - $nBooked) : '–'; + $isFull = $max > 0 && $nBooked >= $max; + + $stats = '' + . statBox($nBooked . '/' . $max, 'Iscritti', $isFull ? '#c0392b' : '#1a7f52') + . statBox((string) $free, 'Posti liberi', '#374151') + . statBox((string) count($pending), 'In attesa', '#8a6d00') + . statBox((string) count($waiting), 'In coda', '#d1698a') + . '
'; + + $lostNote = $nLost > 0 + ? '
di cui ' . $nLost . ' segnat' . ($nLost === 1 ? 'a' : 'e') . ' come pers' . ($nLost === 1 ? 'a' : 'e') . '
' + : ''; + + $body = section('Partecipanti confermati (' . $nBooked . ')', '#374151', personRows($booked, 'booked')); + if ($pending) { + $body .= section('In attesa di conferma (' . count($pending) . ')', '#8a6d00', personRows($pending, 'pending')); + } + if ($waiting) { + $body .= section('Lista d\'attesa (' . count($waiting) . ')', '#d1698a', personRows($waiting, 'waiting')); + } + + return '' . h($cls['servicename']) . '' + . '' + . '
' + . '' + // barra colore classe + . '' + // intestazione + . '' + // statistiche + . '' + // elenchi + . $body + // footer + . '' + . '
 
' + . '
YogiBook · Riepilogo classe
' + . '

' . h($cls['servicename']) . '

' + . '
' . h(italianDate($d)) . ' · ore ' . $d->format('H:i') . '
' + . '
' . h($location) . '
' + . '
' . $stats . '
' . $lostNote . '
Generato automaticamente da YogiBook il ' . date('d/m/Y \a\l\l\e H:i') . '
'; +} + +function renderText(array $cls, array $booked, array $pending, array $waiting, string $location): string +{ + $d = new DateTime($cls['dateschedule']); + $max = (int) $cls['maxcapacity']; + $t = $cls['servicename'] . "\n" . italianDate($d) . ' ore ' . $d->format('H:i') . "\n" . $location . "\n\n"; + $t .= 'Iscritti: ' . count($booked) . '/' . $max . ' | In attesa: ' . count($pending) . ' | In coda: ' . count($waiting) . "\n\n"; + $blocks = ['CONFERMATI' => $booked, 'IN ATTESA DI CONFERMA' => $pending, "LISTA D'ATTESA" => $waiting]; + foreach ($blocks as $title => $list) { + if ($title !== 'CONFERMATI' && !$list) { + continue; + } + $t .= $title . "\n"; + if (!$list) { + $t .= " Nessun partecipante\n"; + } + $i = 1; + foreach ($list as $p) { + $lost = ($p['status'] === 'booked' && ($p['lostlesson'] ?? '') === 'Y') ? ' (PERSA)' : ''; + $t .= ' ' . $i++ . '. ' . fullName($p) . $lost . "\n"; + } + $t .= "\n"; + } + return $t; +} + +/* -------------------------------------------------------------- mailer */ + +function makeMailer(): PHPMailer +{ + $m = new PHPMailer(true); + $m->isSMTP(); + $m->Host = cfg('MAIL_HOST'); + $m->Port = (int) cfg('MAIL_PORT', 465); + $m->SMTPAuth = true; + $m->Username = cfg('MAIL_USERNAME'); + $m->Password = cfg('MAIL_PASSWORD'); + $enc = strtolower((string) cfg('MAIL_ENCRYPTION', 'ssl')); + $m->SMTPSecure = $enc === 'ssl' ? PHPMailer::ENCRYPTION_SMTPS : ($enc === 'tls' ? PHPMailer::ENCRYPTION_STARTTLS : ''); + $m->SMTPKeepAlive = true; + $m->CharSet = 'UTF-8'; + $m->Encoding = 'base64'; + $m->setFrom(cfg('MAIL_FROM_ADDRESS'), cfg('MAIL_FROM_NAME', 'YogiBook')); + $m->isHTML(true); + return $m; +} + +/* --------------------------------------------------------------- invio */ + +$mailer = $dryRun ? null : makeMailer(); +$sent = 0; +$failed = 0; + +foreach ($classes as $cls) { + $sid = (int) $cls['idserviceschedule']; + $all = $bookingsBySchedule[$sid] ?? []; + $booked = array_values(array_filter($all, fn($b) => $b['status'] === 'booked')); + $pending = array_values(array_filter($all, fn($b) => $b['status'] === 'pending')); + $waiting = array_values(array_filter($all, fn($b) => $b['status'] === 'waiting')); + + $d = new DateTime($cls['dateschedule']); + $subject = $cls['servicename'] . ' · ' . $d->format('d/m') . ' ore ' . $d->format('H:i') + . ' · ' . count($booked) . '/' . (int) $cls['maxcapacity'] . ' iscritti'; + if ($pending) { + $subject .= ' · ' . count($pending) . ' in attesa'; + } + if ($waiting) { + $subject .= ' · ' . count($waiting) . ' in coda'; + } + + $html = renderEmail($cls, $booked, $pending, $waiting, $location); + $text = renderText($cls, $booked, $pending, $waiting, $location); + + if ($dryRun) { + $dir = __DIR__ . '/preview'; + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + $file = $dir . '/classe_' . $sid . '.html'; + file_put_contents($file, $html); + logmsg("[DRY-RUN] $subject -> $file"); + continue; + } + + try { + $mailer->clearAddresses(); + foreach ($recipients as $r) { + $mailer->addAddress($r, cfg('TEACHER_NAME', '')); + } + $mailer->Subject = $subject; + $mailer->Body = $html; + $mailer->AltBody = $text; + $mailer->send(); + $sent++; + logmsg("OK: $subject"); + } catch (MailException $e) { + $failed++; + logmsg("ERRORE classe #$sid: " . $mailer->ErrorInfo); + } + sleep(1); // evita limiti di invio del server SMTP +} + +if ($mailer) { + $mailer->smtpClose(); +} + +logmsg("Fine: $sent inviate, $failed errori (" . count($classes) . " classi il $day)."); +exit($failed > 0 ? 1 : 0); diff --git a/cron/preview/classe_314.html b/cron/preview/classe_314.html new file mode 100644 index 0000000..ed5b51d --- /dev/null +++ b/cron/preview/classe_314.html @@ -0,0 +1 @@ +Aerial Yoga - mix
 
YogiBook · Riepilogo classe

Aerial Yoga - mix

Giovedì 24 Settembre 2026 · ore 19:00
via Valassina 62/B Seregno - Sala Contesto Yoga
2/8
Iscritti
6
Posti liberi
0
In attesa
0
In coda
Partecipanti confermati (2)
1.Amelia Benucci
info@claudiosironi.com
Confermata
2.Aurora Masiero
info@yogasoul.it
Confermata
Generato automaticamente da YogiBook il 24/09/2026 alle 10:55
\ No newline at end of file