cron report

This commit is contained in:
2026-09-24 10:57:03 +02:00
parent 9a423414a7
commit 1035f57b92
2 changed files with 328 additions and 0 deletions
+327
View File
@@ -0,0 +1,327 @@
<?php
/**
* YogiBook - Riepilogo classi giornaliero per l'insegnante.
* Invia UNA mail per ogni classe del giorno.
*
* Uso:
* php cron/class_report.php -> 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 '<span style="display:inline-block;background:' . $bg . ';color:' . $fg . ';font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">' . h($label) . '</span>';
}
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 '<tr><td style="padding:16px;color:#9ca3af;font-size:14px;text-align:center;">Nessun partecipante</td></tr>';
}
$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']) ? '<br><span style="font-size:12px;color:#6b7280;font-weight:400;">' . h($p['email']) . '</span>' : '';
$html .= '<tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;">'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>'
. '<td width="34" valign="top" style="font-weight:800;color:' . $numColor . ';font-size:14px;padding-top:1px;">' . $i . ($type === 'waiting' ? '°' : '.') . '</td>'
. '<td valign="top" style="font-size:15px;color:' . $nameColor . ';font-weight:600;">' . h(fullName($p)) . $email . '</td>'
. '<td valign="top" align="right" width="100">' . $chipHtml . '</td>'
. '</tr></table></td></tr>';
$i++;
}
return $html;
}
function section(string $title, string $color, string $rows): string
{
return '<tr><td style="padding:22px 28px 0;">'
. '<div style="font-size:13px;font-weight:700;color:' . $color . ';text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">' . $title . '</div>'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;">' . $rows . '</table>'
. '</td></tr>';
}
function statBox(string $value, string $label, string $color): string
{
return '<td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;">'
. '<div style="font-size:22px;font-weight:800;color:' . $color . ';line-height:1.1;">' . $value . '</div>'
. '<div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">' . $label . '</div>'
. '</td>';
}
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 = '<table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr>'
. 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')
. '</tr></table>';
$lostNote = $nLost > 0
? '<div style="font-size:13px;color:#6b7280;margin-top:6px;">di cui ' . $nLost . ' segnat' . ($nLost === 1 ? 'a' : 'e') . ' come pers' . ($nLost === 1 ? 'a' : 'e') . '</div>'
: '';
$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 '<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . h($cls['servicename']) . '</title></head>'
. '<body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center">'
. '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);">'
// barra colore classe
. '<tr><td style="height:8px;background:' . h($accent) . ';font-size:0;line-height:0;">&nbsp;</td></tr>'
// intestazione
. '<tr><td style="padding:26px 28px 6px;">'
. '<div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div>'
. '<h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">' . h($cls['servicename']) . '</h1>'
. '<div style="font-size:15px;color:#374151;font-weight:600;">' . h(italianDate($d)) . ' · ore ' . $d->format('H:i') . '</div>'
. '<div style="font-size:13px;color:#6b7280;margin-top:4px;">' . h($location) . '</div>'
. '</td></tr>'
// statistiche
. '<tr><td style="padding:16px 22px 0;">' . $stats . '<div style="padding:0 6px;">' . $lostNote . '</div></td></tr>'
// elenchi
. $body
// footer
. '<tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il ' . date('d/m/Y \a\l\l\e H:i') . '</td></tr>'
. '</table></td></tr></table></body></html>';
}
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);
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Aerial Yoga - mix</title></head><body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);"><tr><td style="height:8px;background:#6ED4E4;font-size:0;line-height:0;">&nbsp;</td></tr><tr><td style="padding:26px 28px 6px;"><div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div><h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">Aerial Yoga - mix</h1><div style="font-size:15px;color:#374151;font-weight:600;">Giovedì 24 Settembre 2026 · ore 19:00</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">via Valassina 62/B Seregno - Sala Contesto Yoga</div></td></tr><tr><td style="padding:16px 22px 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#1a7f52;line-height:1.1;">2/8</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Iscritti</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#374151;line-height:1.1;">6</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Posti liberi</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#8a6d00;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In attesa</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#d1698a;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In coda</div></td></tr></table><div style="padding:0 6px;"></div></td></tr><tr><td style="padding:22px 28px 0;"><div style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">Partecipanti confermati (2)</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;"><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">1.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Amelia Benucci<br><span style="font-size:12px;color:#6b7280;font-weight:400;">info@claudiosironi.com</span></td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">2.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Aurora Masiero<br><span style="font-size:12px;color:#6b7280;font-weight:400;">info@yogasoul.it</span></td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr></table></td></tr><tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il 24/09/2026 alle 10:55</td></tr></table></td></tr></table></body></html>