[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; }