[FEAT] Fase 2 cron: reminder/overdue/ricorrenza questionari fornitori

scripts/supplier-questionnaire-runner.php: 3 fasi idempotenti (claim atomico via
UPDATE condizionato) — REMINDER (offset reminder_offsets), OVERDUE (due_at<NOW
-> expired), RICORRENZA (clona campagna ricorrente con nuovo token sq_).
scripts/supplier-questionnaire-cron.sh: wrapper TZ=Europe/Rome + flock + log.

Dry-run su host OK (0 campagne, 0 errori). Crontab 06:00 da registrare via
agile-services CRON_REGISTRY (azione utente/VIGILE). php -l + bash -n OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-31 17:27:50 +02:00
parent 6365d5dfda
commit 261fc4cdd5
2 changed files with 307 additions and 0 deletions

View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
#
# NIS2 Agile — Supplier Questionnaire Cron (Fase 2)
# Wrapper sottile per scripts/supplier-questionnaire-runner.php.
# - TZ=Europe/Rome (standard di progetto)
# - flock in /tmp (no run sovrapposti; il runner è comunque idempotente)
# - log su /var/log/nis2-supplier-cron.log
#
# Crontab root (06:00 Europe/Rome, fuori finestra DST 02:00-03:00):
# 0 6 * * * /var/www/nis2-agile/scripts/supplier-questionnaire-cron.sh
#
set -euo pipefail
export TZ=Europe/Rome
PHP_BIN="${PHP_BIN:-/usr/bin/php}"
RUNNER="/var/www/nis2-agile/scripts/supplier-questionnaire-runner.php"
LOG_FILE="/var/log/nis2-supplier-cron.log"
LOCK_FILE="/tmp/nis2-supplier-questionnaire-cron.lock"
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
touch "$LOG_FILE" 2>/dev/null || true
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "[$(date '+%Y-%m-%d %H:%M:%S %Z')] Lock attivo, run precedente in corso. Uscita." >> "$LOG_FILE"
exit 0
fi
echo "[$(date '+%Y-%m-%d %H:%M:%S %Z')] --- avvio supplier-questionnaire-cron ---" >> "$LOG_FILE"
"$PHP_BIN" "$RUNNER" >> "$LOG_FILE" 2>&1
rc=$?
echo "[$(date '+%Y-%m-%d %H:%M:%S %Z')] --- fine (exit=$rc) ---" >> "$LOG_FILE"
exit "$rc"

View File

@ -0,0 +1,274 @@
#!/usr/bin/env php
<?php
/**
* NIS2 Agile Supplier Questionnaire Runner (Fase 2)
*
* Cron scadenze/reminder per il modulo questionari fornitori.
* Opera sulla tabella questionnaire_campaigns (migrazione 032).
*
* Tre fasi, tutte IDEMPOTENTI (UPDATE condizionato safe se il cron parte 2 volte):
* 1. REMINDER campagne sent/in_progress con next_reminder_at <= NOW(): invia
* email, claim atomico, ricalcola next_reminder_at dal prossimo offset.
* 2. OVERDUE campagne sent/in_progress con due_at < NOW() status='expired'.
* 3. RICORRENZA completed/expired con is_recurring=1 e next_recurrence_at <= NOW()
* clona nuova campagna (token sq_, due_at=now+recurrence_months).
*
* Invocato da scripts/supplier-questionnaire-cron.sh (06:00 Europe/Rome).
*/
declare(strict_types=1);
date_default_timezone_set('Europe/Rome');
define('BASE_PATH', dirname(__DIR__));
define('APP_PATH', BASE_PATH . '/application');
define('PUBLIC_PATH', BASE_PATH . '/public');
require_once APP_PATH . '/config/env.php';
require_once APP_PATH . '/config/config.php';
require_once APP_PATH . '/config/database.php';
require_once APP_PATH . '/services/EmailService.php';
const PORTAL_LINK = 'https://nis2.agile.software/supplier-portal.html';
const LEGACY_BASE = 'https://nis2.agile.software/supplier-assessment.html?token=';
$LOG_FILE = Env::get('SUPPLIER_CRON_LOG', '/var/log/nis2-supplier-cron.log');
$BATCH = max(1, Env::int('SUPPLIER_CRON_BATCH', 500));
qlog($LOG_FILE, '=== Supplier Questionnaire Runner avviato ===');
$summary = ['reminders_sent' => 0, 'reminders_failed' => 0, 'expired' => 0, 'recurrences' => 0, 'errors' => 0];
try {
$summary['reminders_sent'] = runReminders($LOG_FILE, $BATCH, $summary);
$summary['expired'] = runOverdue($LOG_FILE, $BATCH);
$summary['recurrences'] = runRecurrence($LOG_FILE, $BATCH);
} catch (\Throwable $e) {
$summary['errors']++;
qlog($LOG_FILE, 'ERRORE FATALE: ' . $e->getMessage());
}
qlog($LOG_FILE, sprintf(
'SUMMARY reminders_sent=%d reminders_failed=%d expired=%d recurrences=%d errors=%d',
$summary['reminders_sent'], $summary['reminders_failed'],
$summary['expired'], $summary['recurrences'], $summary['errors']
));
qlog($LOG_FILE, '=== Runner completato ===');
exit($summary['errors'] > 0 ? 1 : 0);
// ── FASE 1 — REMINDER ───────────────────────────────────────────────────────
function runReminders(string $log, int $batch, array &$summary): int
{
$rows = Database::fetchAll(
"SELECT c.id, c.organization_id, c.supplier_id, c.sent_to_email, c.due_at,
c.reminder_offsets, c.last_reminder_at, c.language, s.name AS supplier_name
FROM questionnaire_campaigns c
LEFT JOIN suppliers s ON s.id = c.supplier_id
WHERE c.status IN ('sent','in_progress')
AND c.next_reminder_at IS NOT NULL AND c.next_reminder_at <= NOW()
ORDER BY c.next_reminder_at ASC LIMIT {$batch}",
[]
);
if (!$rows) { qlog($log, 'REMINDER: nessuna campagna da sollecitare.'); return 0; }
qlog($log, 'REMINDER: ' . count($rows) . ' candidate.');
$sent = 0;
foreach ($rows as $c) {
$id = (int) $c['id'];
$claimed = Database::query(
"UPDATE questionnaire_campaigns SET next_reminder_at = NULL
WHERE id = ? AND status IN ('sent','in_progress')
AND next_reminder_at IS NOT NULL AND next_reminder_at <= NOW()",
[$id]
)->rowCount();
if ($claimed === 0) { qlog($log, "REMINDER: #{$id} già preso, salto."); continue; }
$email = trim((string) ($c['sent_to_email'] ?? ''));
$ok = $email !== '' ? sendReminderEmail($c, $log) : false;
$offsets = json_decode((string) ($c['reminder_offsets'] ?? '[]'), true);
$offsets = is_array($offsets) ? $offsets : [];
$next = computeNextReminder($c['due_at'] ?? null, $offsets, date('Y-m-d H:i:s'));
if ($ok) {
Database::query(
"UPDATE questionnaire_campaigns
SET reminder_count = reminder_count + 1, last_reminder_at = NOW(), next_reminder_at = ?
WHERE id = ?",
[$next, $id]
);
$sent++;
qlog($log, "REMINDER: #{$id} inviato a " . maskEmail($email) . ($next ? " — prossimo {$next}" : ' — ultimo'));
} else {
$retry = $next ?? computeNextReminder($c['due_at'] ?? null, $offsets, null);
Database::query("UPDATE questionnaire_campaigns SET next_reminder_at = ? WHERE id = ?", [$retry, $id]);
$summary['reminders_failed']++;
qlog($log, "REMINDER: #{$id} invio FALLITO — riprogrammato a " . ($retry ?? 'mai'));
}
}
return $sent;
}
// ── FASE 2 — OVERDUE ────────────────────────────────────────────────────────
function runOverdue(string $log, int $batch): int
{
$n = Database::query(
"UPDATE questionnaire_campaigns SET status = 'expired', next_reminder_at = NULL
WHERE status IN ('sent','in_progress') AND due_at IS NOT NULL AND due_at < NOW()
LIMIT {$batch}",
[]
)->rowCount();
qlog($log, "OVERDUE: {$n} campagne marcate 'expired'.");
return $n;
}
// ── FASE 3 — RICORRENZA ─────────────────────────────────────────────────────
function runRecurrence(string $log, int $batch): int
{
$rows = Database::fetchAll(
"SELECT id, organization_id, supplier_id, template_id, template_version,
show_score_to_supplier, language, sent_to_email, reminder_offsets,
recurrence_months, created_by
FROM questionnaire_campaigns
WHERE status IN ('completed','expired') AND is_recurring = 1
AND next_recurrence_at IS NOT NULL AND next_recurrence_at <= NOW()
ORDER BY next_recurrence_at ASC LIMIT {$batch}",
[]
);
if (!$rows) { qlog($log, 'RICORRENZA: nessuna campagna da riemettere.'); return 0; }
qlog($log, 'RICORRENZA: ' . count($rows) . ' candidate.');
$created = 0;
foreach ($rows as $old) {
$oldId = (int) $old['id'];
$claimed = Database::query(
"UPDATE questionnaire_campaigns SET next_recurrence_at = NULL
WHERE id = ? AND is_recurring = 1
AND next_recurrence_at IS NOT NULL AND next_recurrence_at <= NOW()",
[$oldId]
)->rowCount();
if ($claimed === 0) { qlog($log, "RICORRENZA: #{$oldId} già riemessa, salto."); continue; }
$months = (int) $old['recurrence_months'];
if ($months <= 0) { qlog($log, "RICORRENZA: #{$oldId} recurrence_months non valido, salto."); continue; }
$now = date('Y-m-d H:i:s');
$dueAt = date('Y-m-d H:i:s', strtotime("+{$months} months"));
$offsets = json_decode((string) ($old['reminder_offsets'] ?? '[]'), true);
$offsets = is_array($offsets) ? array_values(array_filter(array_map('intval', $offsets), fn($o) => $o < 0)) : [];
$nextReminderAt = computeNextReminder($dueAt, $offsets, $now);
$nextRecurrence = date('Y-m-d H:i:s', strtotime($dueAt . " +{$months} months"));
$rawToken = 'sq_' . bin2hex(random_bytes(20));
try {
Database::beginTransaction();
$newId = Database::insert('questionnaire_campaigns', [
'organization_id' => (int) $old['organization_id'],
'supplier_id' => (int) $old['supplier_id'],
'template_id' => $old['template_id'] !== null ? (int) $old['template_id'] : null,
'template_version' => $old['template_version'],
'access_token_hash' => hash('sha256', $rawToken),
'status' => 'sent',
'show_score_to_supplier' => (int) $old['show_score_to_supplier'],
'language' => $old['language'] ?: 'it',
'sent_to_email' => $old['sent_to_email'],
'sent_at' => $now,
'due_at' => $dueAt,
'expires_at' => $dueAt,
'reminder_offsets' => json_encode($offsets),
'next_reminder_at' => $nextReminderAt,
'is_recurring' => 1,
'recurrence_months' => $months,
'next_recurrence_at' => $nextRecurrence,
'created_by' => $old['created_by'] !== null ? (int) $old['created_by'] : null,
]);
$email = trim((string) ($old['sent_to_email'] ?? ''));
if ($email !== '') {
$existing = Database::fetchOne('SELECT id FROM supplier_users WHERE supplier_id = ? AND email = ?',
[(int) $old['supplier_id'], $email]);
if (!$existing) {
Database::insert('supplier_users', [
'supplier_id' => (int) $old['supplier_id'], 'organization_id' => (int) $old['organization_id'],
'email' => $email, 'role' => 'contact', 'is_active' => 1,
]);
}
}
Database::commit();
} catch (\Throwable $e) {
if (Database::getInstance()->inTransaction()) Database::rollback();
qlog($log, "RICORRENZA: #{$oldId} clone FALLITO: " . $e->getMessage());
continue;
}
$created++;
qlog($log, "RICORRENZA: #{$oldId} riemessa come #{$newId} (due {$dueAt}).");
$email = trim((string) ($old['sent_to_email'] ?? ''));
if ($email !== '') {
sendInviteEmail(['supplier_name' => fetchSupplierName((int) $old['supplier_id']),
'sent_to_email' => $email, 'due_at' => $dueAt], $rawToken, $log);
}
}
return $created;
}
// ── HELPER ──────────────────────────────────────────────────────────────────
function computeNextReminder(?string $dueAt, array $offsets, ?string $afterTs): ?string
{
if (!$dueAt || empty($offsets)) return null;
$due = strtotime($dueAt);
$after = $afterTs ? strtotime($afterTs) : time();
$candidates = [];
foreach ($offsets as $o) {
$ts = $due + ((int) $o) * 86400;
if ($ts > $after) $candidates[] = $ts;
}
return empty($candidates) ? null : date('Y-m-d H:i:s', min($candidates));
}
function sendReminderEmail(array $c, string $log): bool
{
if (!class_exists('EmailService')) return false;
$supplier = htmlspecialchars((string) ($c['supplier_name'] ?? 'fornitore'), ENT_QUOTES, 'UTF-8');
$dueLabel = !empty($c['due_at']) ? (' entro il <strong>' . date('d/m/Y', strtotime($c['due_at'])) . '</strong>') : '';
$body = "<p>Gentile referente di <strong>{$supplier}</strong>,</p>"
. "<p>vi <strong>ricordiamo</strong> di compilare il questionario di sicurezza richiesto dalla normativa NIS2{$dueLabel}.</p>"
. "<p>Accedete al <strong>portale fornitori</strong> con la vostra email: <a href=\"" . PORTAL_LINK . "\">" . PORTAL_LINK . "</a></p>";
try {
return (new EmailService())->send((string) $c['sent_to_email'], 'Promemoria: questionario di sicurezza fornitore - NIS2', $body);
} catch (\Throwable $e) { qlog($log, '[REMINDER email] ' . $e->getMessage()); return false; }
}
function sendInviteEmail(array $c, string $rawToken, string $log): bool
{
if (!class_exists('EmailService')) return false;
$supplier = htmlspecialchars((string) ($c['supplier_name'] ?? 'fornitore'), ENT_QUOTES, 'UTF-8');
$legacy = LEGACY_BASE . $rawToken;
$dueLabel = !empty($c['due_at']) ? (' entro il ' . date('d/m/Y', strtotime($c['due_at']))) : '';
$body = "<p>Gentile referente di <strong>{$supplier}</strong>,</p>"
. "<p>e' disponibile una <strong>nuova edizione periodica</strong> del questionario di sicurezza NIS2{$dueLabel}.</p>"
. "<p>Accedete al <strong>portale fornitori</strong>: <a href=\"" . PORTAL_LINK . "\">" . PORTAL_LINK . "</a></p>"
. "<p style=\"font-size:13px;color:#666\">In alternativa: <a href=\"{$legacy}\">compila qui</a></p>";
try {
return (new EmailService())->send((string) $c['sent_to_email'], 'Questionario di sicurezza fornitore - NIS2 (rinnovo periodico)', $body);
} catch (\Throwable $e) { qlog($log, '[RICORRENZA email] ' . $e->getMessage()); return false; }
}
function fetchSupplierName(int $supplierId): string
{
$r = Database::fetchOne('SELECT name FROM suppliers WHERE id = ?', [$supplierId]);
return (string) ($r['name'] ?? 'fornitore');
}
function maskEmail(string $email): string
{
$at = strpos($email, '@');
if ($at === false || $at === 0) return '***';
return $email[0] . '***' . substr($email, $at);
}
function qlog(string $logFile, string $message): void
{
$line = '[' . date('Y-m-d H:i:s T') . '] ' . $message . PHP_EOL;
$dir = dirname($logFile);
if (!is_dir($dir)) @mkdir($dir, 0755, true);
@file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
echo $line;
}