[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:
parent
6365d5dfda
commit
261fc4cdd5
33
scripts/supplier-questionnaire-cron.sh
Executable file
33
scripts/supplier-questionnaire-cron.sh
Executable 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"
|
||||||
274
scripts/supplier-questionnaire-runner.php
Normal file
274
scripts/supplier-questionnaire-runner.php
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user