Files
yogibook_aury_new/cron/class_report.php
T
2026-09-24 11:08:03 +02:00

335 lines
14 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
/**
* 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');
// --only-at=17 : esce senza fare nulla se in Italia non sono le 17 (server in UTC)
foreach ($argv as $a) {
if (preg_match('/^--only-at=(\d{1,2})$/', $a, $m) && (int) date('G') !== (int) $m[1]) {
exit(0);
}
}
$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);