200 lines
6.7 KiB
PHP
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;
|
|
}
|