Files
zibo-dashboard/public/userarea/include/training_coverage.php
T
2026-07-24 08:32:50 +02:00

200 lines
6.7 KiB
PHP

<?php
/**
* Helper per la gestione delle coperture tra corsi di formazione.
* Un corso "madre" (topic_id) assorbe uno o più corsi "coperti" (covered_topic_id).
*/
/**
* Mappa completa delle coperture: [topic_id => [covered_topic_id => inherit_expiry]]
*/
function tc_getCoverageMap(PDO $pdo): array
{
static $cache = null;
if ($cache !== null) return $cache;
$rows = $pdo->query("
SELECT topic_id, covered_topic_id, inherit_expiry
FROM training_topic_coverage
")->fetchAll(PDO::FETCH_ASSOC);
$map = [];
foreach ($rows as $r) {
$map[(int)$r['topic_id']][(int)$r['covered_topic_id']] = $r['inherit_expiry'];
}
return $cache = $map;
}
/**
* Mappa inversa: [covered_topic_id => [topic_id, ...]]
*/
function tc_getReverseCoverageMap(PDO $pdo): array
{
static $cache = null;
if ($cache !== null) return $cache;
$map = [];
foreach (tc_getCoverageMap($pdo) as $topicId => $covered) {
foreach (array_keys($covered) as $coveredId) {
$map[$coveredId][] = $topicId;
}
}
return $cache = $map;
}
/**
* Elenco dei corsi coperti da un dato corso, con i nomi.
* @return array[] [['id'=>int,'name'=>string,'inherit_expiry'=>string], ...]
*/
function tc_getCoveredTopics(PDO $pdo, int $topicId): array
{
$st = $pdo->prepare("
SELECT tt.id, tt.name, c.inherit_expiry
FROM training_topic_coverage c
JOIN training_topics tt ON tt.id = c.covered_topic_id
WHERE c.topic_id = :tid
ORDER BY tt.sort_order, tt.name
");
$st->execute(['tid' => $topicId]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Calcola la data di scadenza per un corso coperto.
*
* @param string $completedDate data completamento del corso madre (Y-m-d)
* @param string $inherit 'own' | 'source' | 'none'
* @param int|null $coveredFreq frequenza (mesi) del corso coperto
* @param string|null $sourceNextDue next_due_date del corso madre
*/
function tc_computeDueDate(
string $completedDate,
string $inherit,
?int $coveredFreq,
?string $sourceNextDue
): ?string {
if ($inherit === 'none') return null;
if ($inherit === 'source') return $sourceNextDue ?: null;
// 'own'
if ($coveredFreq === null || $coveredFreq <= 0) return null;
$d = DateTime::createFromFormat('Y-m-d', $completedDate);
if (!$d) return null;
$d->modify('+' . $coveredFreq . ' months');
return $d->format('Y-m-d');
}
/**
* Dato l'elenco dei training reali di uno o più dipendenti, produce le
* righe "virtuali" derivate dalle coperture.
*
* @param array $realRows righe da employee_trainings (servono almeno:
* employee_id, training_topic_id, completed_date,
* next_due_date, e i dati anagrafici del dipendente)
* @param array $topicsById [id => ['name'=>..,'default_frequency_months'=>..,'default_reminder_days'=>..]]
* @return array righe virtuali pronte per il rendering
*/
function tc_buildVirtualRows(PDO $pdo, array $realRows, array $topicsById): array
{
$coverage = tc_getCoverageMap($pdo);
if (!$coverage) return [];
// Indice dei record diretti già esistenti: "empId:topicId"
$direct = [];
foreach ($realRows as $r) {
$direct[$r['employee_id'] . ':' . $r['training_topic_id']] = true;
}
$virtual = [];
foreach ($realRows as $r) {
$srcTopicId = (int)$r['training_topic_id'];
if (empty($coverage[$srcTopicId])) continue;
foreach ($coverage[$srcTopicId] as $coveredId => $inherit) {
$key = $r['employee_id'] . ':' . $coveredId;
// Precedenza al record diretto
if (isset($direct[$key])) continue;
// Il corso coperto deve esistere ed essere attivo
if (!isset($topicsById[$coveredId])) continue;
// Evita doppioni se più corsi madre coprono lo stesso corso:
// tiene quello con completed_date più recente
if (isset($virtual[$key]) && $virtual[$key]['completed_date'] >= $r['completed_date']) continue;
$covFreq = $topicsById[$coveredId]['default_frequency_months'];
$covFreq = ($covFreq === null || $covFreq === '') ? null : (int)$covFreq;
$due = tc_computeDueDate(
(string)$r['completed_date'],
(string)$inherit,
$covFreq,
$r['next_due_date'] ?: null
);
$virtual[$key] = [
'id' => null,
'_covered' => true,
'_covered_by_id' => $srcTopicId,
'_covered_by_name' => $topicsById[$srcTopicId]['name'] ?? ('#' . $srcTopicId),
'employee_id' => $r['employee_id'],
'first_name' => $r['first_name'] ?? '',
'last_name' => $r['last_name'] ?? '',
'employee_code' => $r['employee_code'] ?? null,
'department_name' => $r['department_name'] ?? null,
'department_color' => $r['department_color'] ?? null,
'training_topic_id' => $coveredId,
'topic_name' => $topicsById[$coveredId]['name'],
'topic_default_rem' => $topicsById[$coveredId]['default_reminder_days'] ?? null,
'training_type' => $r['training_type'] ?? 'initial',
'completed_date' => $r['completed_date'],
'next_due_date' => $due,
'reminder_days' => null,
'attachments_count' => 0,
];
}
}
return array_values($virtual);
}
/**
* Insieme di chiavi "empId:topicId" coperte, usato per escludere
* i corsi obbligatori dai "Non presenti".
*/
function tc_coveredKeys(array $virtualRows): array
{
$keys = [];
foreach ($virtualRows as $v) {
$keys[$v['employee_id'] . ':' . $v['training_topic_id']] = true;
}
return $keys;
}
/**
* Verifica se aggiungere topicId -> coveredId creerebbe un ciclo.
*/
function tc_wouldCreateCycle(PDO $pdo, int $topicId, int $coveredId): bool
{
if ($topicId === $coveredId) return true;
$map = [];
$rows = $pdo->query("SELECT topic_id, covered_topic_id FROM training_topic_coverage")
->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$map[(int)$r['topic_id']][] = (int)$r['covered_topic_id'];
}
$map[$topicId][] = $coveredId;
// DFS da coveredId: se raggiungo topicId c'è ciclo
$stack = [$coveredId];
$visited = [];
while ($stack) {
$cur = array_pop($stack);
if ($cur === $topicId) return true;
if (isset($visited[$cur])) continue;
$visited[$cur] = true;
foreach ($map[$cur] ?? [] as $next) $stack[] = $next;
}
return false;
}