diff --git a/application/controllers/CompetenceController.php b/application/controllers/CompetenceController.php new file mode 100644 index 0000000..631b49a --- /dev/null +++ b/application/controllers/CompetenceController.php @@ -0,0 +1,700 @@ +corso + * (skill_course_map), calcolo del GAP competenze e apertura di un'azione + * correttiva dal gap riusando il workflow NCR/CAPA esistente. + * + * Multi-tenancy ancorata a getCurrentOrgId(); scritture riservate a + * org_admin/compliance_manager (super_admin bypassa). Anti-IDOR su (id + + * organization_id). Le competenze globali (skills.organization_id NULL) sono in + * sola lettura per gli utenti dell'org (solo super_admin le gestisce). + * + * Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md sez.3/4): PR.AT-01 (tutti), + * PR.AT-02 (solo soggetti essenziali), GV.RR-04 (cyber nelle pratiche HR). + * + * NB: la tabella delle azioni e 'capa_actions' (figlia di 'non_conformities'), + * NON 'corrective_actions' (che non esiste). openAction() crea una NCR ancorata + * al gap (source_entity_type='competence_gap') + una CAPA collegata. + */ + +require_once __DIR__ . '/BaseController.php'; + +class CompetenceController extends BaseController +{ + private const MANAGE_ROLES = ['org_admin', 'compliance_manager']; + + // ═══════════════════════════════════════════════════════════════════════ + // CATALOGO COMPETENZE (skills) + // ═══════════════════════════════════════════════════════════════════════ + + /** GET /api/competences/catalog — skill visibili (org + globali) + corsi mappati. */ + public function catalog(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + $skills = Database::fetchAll( + 'SELECT s.id, s.organization_id, s.name, s.area, s.description, s.created_at, s.updated_at + FROM skills s + WHERE s.organization_id = ? OR s.organization_id IS NULL + ORDER BY (s.organization_id IS NULL) DESC, s.area, s.name', + [$orgId] + ); + + // Corsi mappati per ciascuna skill (corsi visibili: org o globali). + $maps = Database::fetchAll( + 'SELECT scm.id AS map_id, scm.skill_id, scm.training_course_id, tc.title + FROM skill_course_map scm + JOIN training_courses tc ON tc.id = scm.training_course_id + WHERE tc.organization_id = ? OR tc.organization_id IS NULL', + [$orgId] + ); + $coursesBySkill = []; + foreach ($maps as $m) { + $coursesBySkill[(int) $m['skill_id']][] = [ + 'map_id' => (int) $m['map_id'], + 'course_id' => (int) $m['training_course_id'], + 'title' => $m['title'], + ]; + } + + $isSuper = ($this->currentUser['role'] ?? '') === 'super_admin'; + $out = array_map(function ($s) use ($coursesBySkill, $isSuper, $orgId) { + $s = $this->normalizeSkill($s); + $s['courses'] = $coursesBySkill[$s['id']] ?? []; + $s['editable'] = $s['is_global'] ? $isSuper : ($s['organization_id'] === $orgId); + return $s; + }, $skills); + + $this->jsonSuccess([ + 'skills' => $out, + 'total' => count($out), + ]); + } + + /** POST /api/competences/skills — {name, area?, description?, global?(super_admin)} */ + public function createSkill(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + + $name = trim((string) $this->getParam('name', '')); + if ($name === '') { + $this->jsonError('Il nome della competenza e obbligatorio', 422, 'SKILL_NAME_REQUIRED'); + } + if (mb_strlen($name) > 150) { + $this->jsonError('Il nome della competenza supera 150 caratteri', 422, 'SKILL_NAME_TOO_LONG'); + } + + // Solo super_admin puo creare competenze globali (organization_id NULL). + $isSuper = ($this->currentUser['role'] ?? '') === 'super_admin'; + $orgId = ($isSuper && $this->getParam('global')) ? null : $this->getCurrentOrgId(); + + $id = Database::insert('skills', [ + 'organization_id' => $orgId, + 'name' => $name, + 'area' => $this->nullableText($this->getParam('area'), 100), + 'description' => $this->nullableText($this->getParam('description')), + 'created_by' => $this->getCurrentUserId(), + ]); + + $this->logAudit('skill_created', 'skill', $id, ['name' => $name, 'global' => $orgId === null]); + $skill = Database::fetchOne('SELECT * FROM skills WHERE id = ?', [$id]); + $this->jsonSuccess($this->normalizeSkill($skill), 'Competenza creata', 201); + } + + /** PUT /api/competences/skills/{id} */ + public function updateSkill(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $skill = $this->fetchVisibleSkillOrFail($id); + $this->assertSkillWritable($skill); + + $updates = []; + if ($this->hasParam('name')) { + $name = trim((string) $this->getParam('name', '')); + if ($name === '') { + $this->jsonError('Il nome della competenza e obbligatorio', 422, 'SKILL_NAME_REQUIRED'); + } + if (mb_strlen($name) > 150) { + $this->jsonError('Il nome della competenza supera 150 caratteri', 422, 'SKILL_NAME_TOO_LONG'); + } + $updates['name'] = $name; + } + if ($this->hasParam('area')) { + $updates['area'] = $this->nullableText($this->getParam('area'), 100); + } + if ($this->hasParam('description')) { + $updates['description'] = $this->nullableText($this->getParam('description')); + } + if (empty($updates)) { + $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); + } + + Database::update('skills', $updates, 'id = ?', [$id]); + $this->logAudit('skill_updated', 'skill', $id, $updates); + $skill = Database::fetchOne('SELECT * FROM skills WHERE id = ?', [$id]); + $this->jsonSuccess($this->normalizeSkill($skill), 'Competenza aggiornata'); + } + + /** DELETE /api/competences/skills/{id} — cascata su role_skills/user_skills/skill_course_map. */ + public function deleteSkill(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $skill = $this->fetchVisibleSkillOrFail($id); + $this->assertSkillWritable($skill); + + $usedRole = Database::count('role_skills', 'skill_id = ?', [$id]); + $usedUser = Database::count('user_skills', 'skill_id = ?', [$id]); + + Database::delete('skills', 'id = ?', [$id]); + $this->logAudit('skill_deleted', 'skill', $id, ['role_links' => $usedRole, 'user_links' => $usedUser]); + $this->jsonSuccess([ + 'removed_role_links' => $usedRole, + 'removed_user_links' => $usedUser, + ], 'Competenza eliminata'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // REQUISITI PER RUOLO (role_skills) + // ═══════════════════════════════════════════════════════════════════════ + + /** GET /api/competences/roleSkills/{roleId} — competenze richieste dal ruolo. */ + public function roleSkills(int $roleId): void + { + $this->requireOrgAccess(); + $this->fetchRoleOrFail($roleId); + + $rows = Database::fetchAll( + 'SELECT rs.id, rs.role_id, rs.skill_id, rs.required_level, s.name AS skill_name, s.area + FROM role_skills rs JOIN skills s ON s.id = rs.skill_id + WHERE rs.role_id = ? AND rs.organization_id = ? + ORDER BY s.area, s.name', + [$roleId, $this->getCurrentOrgId()] + ); + $this->jsonSuccess(array_map(fn($r) => [ + 'id' => (int) $r['id'], + 'role_id' => (int) $r['role_id'], + 'skill_id' => (int) $r['skill_id'], + 'skill_name' => $r['skill_name'], + 'area' => $r['area'], + 'required_level' => (int) $r['required_level'], + ], $rows)); + } + + /** POST /api/competences/roleSkills — {role_id, skill_id, required_level} upsert. */ + public function setRoleSkill(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $this->validateRequired(['role_id', 'skill_id']); + $orgId = $this->getCurrentOrgId(); + + $roleId = (int) $this->getParam('role_id'); + $skillId = (int) $this->getParam('skill_id'); + $level = $this->clampLevel($this->getParam('required_level', 3)); + + $this->fetchRoleOrFail($roleId); + $this->fetchVisibleSkillOrFail($skillId); + + $existing = Database::fetchOne( + 'SELECT id FROM role_skills WHERE role_id = ? AND skill_id = ?', + [$roleId, $skillId] + ); + if ($existing) { + Database::update('role_skills', ['required_level' => $level], 'id = ?', [(int) $existing['id']]); + $id = (int) $existing['id']; + $created = false; + } else { + $id = Database::insert('role_skills', [ + 'organization_id' => $orgId, + 'role_id' => $roleId, + 'skill_id' => $skillId, + 'required_level' => $level, + 'created_by' => $this->getCurrentUserId(), + ]); + $created = true; + } + + $this->logAudit('role_skill_set', 'role_skill', $id, ['role_id' => $roleId, 'skill_id' => $skillId, 'required_level' => $level]); + $this->jsonSuccess(['id' => $id, 'required_level' => $level], $created ? 'Requisito aggiunto' : 'Requisito aggiornato', $created ? 201 : 200); + } + + /** DELETE /api/competences/roleSkills/{id} */ + public function removeRoleSkill(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $row = Database::fetchOne('SELECT id FROM role_skills WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + if (!$row) { + $this->jsonError('Requisito non trovato', 404, 'ROLE_SKILL_NOT_FOUND'); + } + Database::delete('role_skills', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + $this->logAudit('role_skill_removed', 'role_skill', $id, null); + $this->jsonSuccess(null, 'Requisito rimosso'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // COMPETENZE POSSEDUTE (user_skills) + // ═══════════════════════════════════════════════════════════════════════ + + /** GET /api/competences/userSkills/{userId} — competenze possedute dall'utente. */ + public function userSkills(int $userId): void + { + $this->requireOrgAccess(); + $this->assertMember($userId); + + $rows = Database::fetchAll( + 'SELECT us.id, us.user_id, us.skill_id, us.level, us.acquired_via_course_id, us.evidence, + s.name AS skill_name, s.area, tc.title AS course_title + FROM user_skills us + JOIN skills s ON s.id = us.skill_id + LEFT JOIN training_courses tc ON tc.id = us.acquired_via_course_id + WHERE us.user_id = ? AND us.organization_id = ? + ORDER BY s.area, s.name', + [$userId, $this->getCurrentOrgId()] + ); + $this->jsonSuccess(array_map(fn($r) => [ + 'id' => (int) $r['id'], + 'user_id' => (int) $r['user_id'], + 'skill_id' => (int) $r['skill_id'], + 'skill_name' => $r['skill_name'], + 'area' => $r['area'], + 'level' => (int) $r['level'], + 'acquired_via_course_id' => $r['acquired_via_course_id'] !== null ? (int) $r['acquired_via_course_id'] : null, + 'course_title' => $r['course_title'], + 'evidence' => $r['evidence'], + ], $rows)); + } + + /** POST /api/competences/userSkills — {user_id, skill_id, level, evidence?, acquired_via_course_id?} upsert. */ + public function setUserSkill(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $this->validateRequired(['user_id', 'skill_id']); + $orgId = $this->getCurrentOrgId(); + + $userId = (int) $this->getParam('user_id'); + $skillId = (int) $this->getParam('skill_id'); + $level = $this->clampLevel($this->getParam('level', 1)); + + $this->assertMember($userId); + $this->fetchVisibleSkillOrFail($skillId); + $courseId = $this->validateCourse($this->getParam('acquired_via_course_id')); + + $existing = Database::fetchOne( + 'SELECT id FROM user_skills WHERE organization_id = ? AND user_id = ? AND skill_id = ?', + [$orgId, $userId, $skillId] + ); + $data = [ + 'level' => $level, + 'acquired_via_course_id' => $courseId, + 'evidence' => $this->nullableText($this->getParam('evidence'), 500), + 'assessed_at' => date('Y-m-d H:i:s'), + ]; + if ($existing) { + Database::update('user_skills', $data, 'id = ?', [(int) $existing['id']]); + $id = (int) $existing['id']; + $created = false; + } else { + $data['organization_id'] = $orgId; + $data['user_id'] = $userId; + $data['skill_id'] = $skillId; + $data['created_by'] = $this->getCurrentUserId(); + $id = Database::insert('user_skills', $data); + $created = true; + } + + $this->logAudit('user_skill_set', 'user_skill', $id, ['user_id' => $userId, 'skill_id' => $skillId, 'level' => $level]); + $this->jsonSuccess(['id' => $id, 'level' => $level], $created ? 'Competenza registrata' : 'Competenza aggiornata', $created ? 201 : 200); + } + + /** DELETE /api/competences/userSkills/{id} */ + public function removeUserSkill(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $row = Database::fetchOne('SELECT id FROM user_skills WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + if (!$row) { + $this->jsonError('Competenza non trovata', 404, 'USER_SKILL_NOT_FOUND'); + } + Database::delete('user_skills', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + $this->logAudit('user_skill_removed', 'user_skill', $id, null); + $this->jsonSuccess(null, 'Competenza rimossa'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // MAPPATURA COMPETENZA <-> CORSO (skill_course_map) + // ═══════════════════════════════════════════════════════════════════════ + + /** POST /api/competences/skillCourses — {skill_id, training_course_id} */ + public function mapCourse(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $this->validateRequired(['skill_id', 'training_course_id']); + + $skillId = (int) $this->getParam('skill_id'); + $courseId = (int) $this->getParam('training_course_id'); + + $skill = $this->fetchVisibleSkillOrFail($skillId); + $this->assertSkillWritable($skill); + if ($this->validateCourse($courseId) === null) { + $this->jsonError('Corso non valido per questa organizzazione', 422, 'INVALID_COURSE'); + } + + $existing = Database::fetchOne( + 'SELECT id FROM skill_course_map WHERE skill_id = ? AND training_course_id = ?', + [$skillId, $courseId] + ); + if ($existing) { + $this->jsonSuccess(['id' => (int) $existing['id']], 'Corso gia mappato', 200); + } + $id = Database::insert('skill_course_map', [ + 'skill_id' => $skillId, + 'training_course_id' => $courseId, + 'created_by' => $this->getCurrentUserId(), + ]); + $this->logAudit('skill_course_mapped', 'skill', $skillId, ['training_course_id' => $courseId]); + $this->jsonSuccess(['id' => $id], 'Corso mappato', 201); + } + + /** DELETE /api/competences/skillCourses/{id} */ + public function unmapCourse(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + // Verifica che il mapping riguardi una skill gestibile dall'org corrente. + $row = Database::fetchOne( + 'SELECT scm.id, s.organization_id + FROM skill_course_map scm JOIN skills s ON s.id = scm.skill_id + WHERE scm.id = ? AND (s.organization_id = ? OR s.organization_id IS NULL)', + [$id, $this->getCurrentOrgId()] + ); + if (!$row) { + $this->jsonError('Mappatura non trovata', 404, 'MAP_NOT_FOUND'); + } + $isGlobal = $row['organization_id'] === null; + if ($isGlobal && ($this->currentUser['role'] ?? '') !== 'super_admin') { + $this->jsonError('Le competenze globali sono gestite dal fornitore', 403, 'SKILL_GLOBAL_READONLY'); + } + Database::delete('skill_course_map', 'id = ?', [$id]); + $this->logAudit('skill_course_unmapped', 'skill', (int) ($row['organization_id'] ?? 0), ['map_id' => $id]); + $this->jsonSuccess(null, 'Mappatura rimossa'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // GAP COMPETENZE + ALERT->AZIONE + // ═══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/competences/gapGrid + * Per ogni ruolo con titolare: competenze richieste vs possedute, gap, + * corsi suggeriti e se esiste gia un'azione (NCR) aperta sul gap. + */ + public function gapGrid(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + $org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]); + $entityType = $org['entity_type'] ?? 'not_applicable'; + + $rows = Database::fetchAll( + 'SELECT r.id AS role_id, r.role_name, r.holder_user_id, u.full_name AS holder_name, + rs.id AS role_skill_id, rs.skill_id, s.name AS skill_name, s.area, + rs.required_level, COALESCE(us.level, 0) AS current_level + FROM org_roles r + JOIN users u ON u.id = r.holder_user_id + JOIN role_skills rs ON rs.role_id = r.id + JOIN skills s ON s.id = rs.skill_id + LEFT JOIN user_skills us + ON us.user_id = r.holder_user_id AND us.skill_id = rs.skill_id AND us.organization_id = r.organization_id + WHERE r.organization_id = ? AND r.holder_user_id IS NOT NULL + ORDER BY r.sort_order, r.role_name, s.area, s.name', + [$orgId] + ); + + // Corsi suggeriti per skill (corsi visibili: org o globali). + $maps = Database::fetchAll( + 'SELECT scm.skill_id, tc.id AS course_id, tc.title + FROM skill_course_map scm JOIN training_courses tc ON tc.id = scm.training_course_id + WHERE tc.organization_id = ? OR tc.organization_id IS NULL', + [$orgId] + ); + $coursesBySkill = []; + foreach ($maps as $m) { + $coursesBySkill[(int) $m['skill_id']][] = ['id' => (int) $m['course_id'], 'title' => $m['title']]; + } + + // Azioni (NCR) gia aperte ancorate a un gap, indicizzate per role_skill_id. + $openNcr = Database::fetchAll( + "SELECT source_entity_id, id, ncr_code, status FROM non_conformities + WHERE organization_id = ? AND source_entity_type = 'competence_gap' + AND status NOT IN ('closed','cancelled')", + [$orgId] + ); + $ncrByRoleSkill = []; + foreach ($openNcr as $n) { + $ncrByRoleSkill[(int) $n['source_entity_id']] = ['ncr_id' => (int) $n['id'], 'ncr_code' => $n['ncr_code'], 'status' => $n['status']]; + } + + $rolesById = []; + $totalGaps = 0; + foreach ($rows as $r) { + $roleId = (int) $r['role_id']; + if (!isset($rolesById[$roleId])) { + $rolesById[$roleId] = [ + 'role_id' => $roleId, + 'role_name' => $r['role_name'], + 'holder_user_id' => (int) $r['holder_user_id'], + 'holder_name' => $r['holder_name'], + 'skills' => [], + 'gap_count' => 0, + ]; + } + $required = (int) $r['required_level']; + $current = (int) $r['current_level']; + $gap = max(0, $required - $current); + $roleSkillId = (int) $r['role_skill_id']; + if ($gap > 0) { + $totalGaps++; + $rolesById[$roleId]['gap_count']++; + } + $rolesById[$roleId]['skills'][] = [ + 'role_skill_id' => $roleSkillId, + 'skill_id' => (int) $r['skill_id'], + 'skill_name' => $r['skill_name'], + 'area' => $r['area'], + 'required_level' => $required, + 'current_level' => $current, + 'gap' => $gap, + 'suggested_courses'=> $coursesBySkill[(int) $r['skill_id']] ?? [], + 'open_action' => $ncrByRoleSkill[$roleSkillId] ?? null, + ]; + } + + $roles = array_values($rolesById); + $this->jsonSuccess([ + 'entity_type' => $entityType, + 'pr_at_02_applies' => $entityType === 'essential', + 'roles' => $roles, + 'roles_with_holder' => count($roles), + 'roles_with_gaps' => count(array_filter($roles, fn($r) => $r['gap_count'] > 0)), + 'total_gaps' => $totalGaps, + ]); + } + + /** + * POST /api/competences/openAction — {role_skill_id} + * Apre una Non Conformita (NCR) ancorata al gap + una CAPA collegata, + * riusando il workflow NCR/CAPA esistente. Idempotente: se esiste gia una + * NCR aperta per lo stesso gap, ritorna quella (409). + */ + public function openAction(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $this->validateRequired(['role_skill_id']); + $orgId = $this->getCurrentOrgId(); + + $roleSkillId = (int) $this->getParam('role_skill_id'); + $rs = Database::fetchOne( + 'SELECT rs.id, rs.role_id, rs.skill_id, rs.required_level, s.name AS skill_name, + r.role_name, r.holder_user_id + FROM role_skills rs + JOIN skills s ON s.id = rs.skill_id + JOIN org_roles r ON r.id = rs.role_id + WHERE rs.id = ? AND rs.organization_id = ?', + [$roleSkillId, $orgId] + ); + if (!$rs) { + $this->jsonError('Requisito di competenza non trovato', 404, 'ROLE_SKILL_NOT_FOUND'); + } + if ($rs['holder_user_id'] === null) { + $this->jsonError('Il ruolo non ha un titolare: assegna prima un titolare', 422, 'ROLE_NO_HOLDER'); + } + + $holderId = (int) $rs['holder_user_id']; + $required = (int) $rs['required_level']; + $cur = Database::fetchOne( + 'SELECT level FROM user_skills WHERE organization_id = ? AND user_id = ? AND skill_id = ?', + [$orgId, $holderId, (int) $rs['skill_id']] + ); + $current = $cur ? (int) $cur['level'] : 0; + $gap = $required - $current; + if ($gap <= 0) { + $this->jsonError('Nessun gap di competenza su questo requisito', 422, 'NO_GAP'); + } + + // Anti-duplicato: NCR gia aperta per lo stesso gap. + $dup = Database::fetchOne( + "SELECT id, ncr_code FROM non_conformities + WHERE organization_id = ? AND source_entity_type = 'competence_gap' AND source_entity_id = ? + AND status NOT IN ('closed','cancelled')", + [$orgId, $roleSkillId] + ); + if ($dup) { + $this->jsonError('Esiste gia una non conformita aperta per questo gap (' . $dup['ncr_code'] . ')', 409, 'ACTION_EXISTS', [ + 'ncr_id' => (int) $dup['id'], + 'ncr_code' => $dup['ncr_code'], + ]); + } + + // PR.AT-02 vale solo per i soggetti essenziali; PR.AT-01 per tutti. + $org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]); + $prAt = (($org['entity_type'] ?? '') === 'essential') ? 'PR.AT-02' : 'PR.AT-01'; + $severity = $gap >= 2 ? 'major' : 'minor'; + + $title = 'Gap competenza: ' . $rs['skill_name'] . ' (' . $rs['role_name'] . ')'; + $desc = "Gap di competenza rilevato dall'organigramma.\n" + . 'Ruolo: ' . $rs['role_name'] . "\n" + . 'Competenza: ' . $rs['skill_name'] . "\n" + . 'Livello richiesto: ' . $required . ' / Posseduto: ' . $current . ' (gap: ' . $gap . ")\n" + . 'Riferimento: ' . $prAt . ' (formazione e consapevolezza); GV.RR-04 (cyber nelle pratiche HR). ' + . 'Gli obblighi di gestione del rischio fanno capo all\'art. 24 D.Lgs. 138/2024.'; + + Database::beginTransaction(); + try { + $ncrCode = $this->generateCode('NCR'); + $ncrId = Database::insert('non_conformities', [ + 'organization_id' => $orgId, + 'ncr_code' => $ncrCode, + 'title' => mb_substr($title, 0, 255), + 'description' => $desc, + 'source' => 'management_review', + 'source_entity_type'=> 'competence_gap', + 'source_entity_id' => $roleSkillId, + 'severity' => $severity, + 'category' => 'Competenze', + 'nis2_article' => $prAt, + 'status' => 'action_planned', + 'identified_by' => $this->getCurrentUserId(), + 'assigned_to' => $holderId, + ]); + + $capaCode = $this->generateCode('CAPA'); + $capaId = Database::insert('capa_actions', [ + 'ncr_id' => $ncrId, + 'organization_id' => $orgId, + 'capa_code' => $capaCode, + 'action_type' => 'corrective', + 'title' => mb_substr('Colmare il gap di competenza: ' . $rs['skill_name'], 0, 255), + 'description' => 'Pianificare formazione/affiancamento per portare ' . $rs['skill_name'] + . ' al livello ' . $required . '. Valutare i corsi mappati alla competenza.', + 'status' => 'planned', + 'responsible_user_id'=> $holderId, + ]); + + Database::commit(); + } catch (Throwable $e) { + Database::rollback(); + throw $e; + } + + $this->logAudit('competence_action_opened', 'non_conformity', $ncrId, [ + 'role_skill_id' => $roleSkillId, 'capa_id' => $capaId, 'gap' => $gap, + ]); + $this->jsonSuccess([ + 'ncr_id' => $ncrId, + 'ncr_code' => $ncrCode, + 'capa_id' => $capaId, + 'capa_code' => $capaCode, + ], 'Non conformita e azione correttiva create', 201); + } + + // ═══════════════════════════════════════════════════════════════════════ + // PRIVATI + // ═══════════════════════════════════════════════════════════════════════ + + /** Skill visibile all'org (org-owned o globale) oppure 404. */ + private function fetchVisibleSkillOrFail(int $id): array + { + $s = Database::fetchOne( + 'SELECT * FROM skills WHERE id = ? AND (organization_id = ? OR organization_id IS NULL)', + [$id, $this->getCurrentOrgId()] + ); + if (!$s) { + $this->jsonError('Competenza non trovata', 404, 'SKILL_NOT_FOUND'); + } + return $s; + } + + /** Una skill e scrivibile se appartiene all'org; le globali solo da super_admin. */ + private function assertSkillWritable(array $skill): void + { + $isGlobal = $skill['organization_id'] === null; + if ($isGlobal && ($this->currentUser['role'] ?? '') !== 'super_admin') { + $this->jsonError('Le competenze globali sono gestite dal fornitore', 403, 'SKILL_GLOBAL_READONLY'); + } + } + + /** Ruolo dell'organigramma nell'org corrente oppure 404 (anti-IDOR). */ + private function fetchRoleOrFail(int $id): array + { + $r = Database::fetchOne('SELECT * FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + if (!$r) { + $this->jsonError('Ruolo non trovato', 404, 'ROLE_NOT_FOUND'); + } + return $r; + } + + /** L'utente deve essere membro dell'org corrente. */ + private function assertMember(int $userId): void + { + $m = Database::fetchOne( + 'SELECT id FROM user_organizations WHERE user_id = ? AND organization_id = ?', + [$userId, $this->getCurrentOrgId()] + ); + if (!$m) { + $this->jsonError('Utente non membro dell organizzazione', 422, 'USER_NOT_MEMBER'); + } + } + + /** Corso visibile (org o globale) oppure null se non fornito; jsonError se id non valido. */ + private function validateCourse($raw): ?int + { + if ($raw === null || $raw === '' || (int) $raw === 0) { + return null; + } + $courseId = (int) $raw; + $c = Database::fetchOne( + 'SELECT id FROM training_courses WHERE id = ? AND (organization_id = ? OR organization_id IS NULL)', + [$courseId, $this->getCurrentOrgId()] + ); + if (!$c) { + $this->jsonError('Corso non valido per questa organizzazione', 422, 'INVALID_COURSE'); + } + return $courseId; + } + + private function clampLevel($raw): int + { + $n = (int) $raw; + if ($n < 1) { $n = 1; } + if ($n > 5) { $n = 5; } + return $n; + } + + private function nullableText($v, int $max = 4000): ?string + { + if ($v === null) { + return null; + } + $v = trim((string) $v); + if ($v === '') { + return null; + } + return mb_substr($v, 0, $max); + } + + private function normalizeSkill(array $s): array + { + return [ + 'id' => (int) $s['id'], + 'organization_id' => $s['organization_id'] !== null ? (int) $s['organization_id'] : null, + 'is_global' => $s['organization_id'] === null, + 'name' => $s['name'], + 'area' => $s['area'], + 'description' => $s['description'] ?? null, + 'created_at' => $s['created_at'] ?? null, + 'updated_at' => $s['updated_at'] ?? null, + ]; + } +} diff --git a/docs/DESIGN_A4_RELATIONAL.md b/docs/DESIGN_A4_RELATIONAL.md index ca00e81..7c49ce8 100644 --- a/docs/DESIGN_A4_RELATIONAL.md +++ b/docs/DESIGN_A4_RELATIONAL.md @@ -1,7 +1,11 @@ # DESIGN — A4: Modello relazionale (organigramma RACI ↔ skill / formazione / inventario / procedure / rischi + scadenziario) -> Stato: **DESIGN (pre-implementazione)**. Contesto: segnalazioni Simon Fattori, punti **2, 3, 4, 5, 6, 7** (accolti in A4). Data: 2026-06-15. +> Stato: **IN CORSO**. Contesto: segnalazioni Simon Fattori, punti **2, 3, 4, 5, 6, 7** (accolti in A4). Data: 2026-06-15. > È l'epic più grande → da realizzare a **FASI**. Ancoraggio normativo verificato (Det. ACN 164179/2025 + D.Lgs. 138/2024). Vedi `reference-dlgs-articles`, `DESIGN_A2_ONBOARDING.md`. +> +> **AVANZAMENTO**: ✅ **4.1 Organigramma** (migration `041_org_roles.sql`, `OrgRoleController`, `organigramma.html`) — LIVE. ✅ **4.2 Competenze** (migration `042_competences.sql`: `skills`/`role_skills`/`user_skills`/`skill_course_map`; `CompetenceController`; `competenze.html`) — LIVE. ⏳ 4.3 RACI · 4.4 scadenziario · 4.5 stakeholder. +> +> 🔴 **RETTIFICA NOMI REALI (verificata sul codice)**: questo design parlava di una tabella **`corrective_actions`** che **NON ESISTE**. La tabella delle azioni reale è **`capa_actions`** (mig.004), **figlia obbligatoria** di **`non_conformities`** (NCR) via `ncr_id NOT NULL`, e **non ha** una colonna `source_type`. L'alert "gap → azione" (impl. in 4.2) quindi **NON** crea `corrective_actions`/`source_type`: crea una **`non_conformities`** ancorata al gap (`source='management_review'`, `source_entity_type='competence_gap'`, `source_entity_id=role_skills.id`) + una **`capa_actions`** figlia. Le menzioni di `corrective_actions`/`source_type` qui sotto vanno lette in questo senso. ## 0. Principio A4 trasforma i moduli oggi "a silos" in un **grafo relazionale** con al centro l'**organigramma** (ruoli/responsabilità) e la **matrice RACI** come hub che lega i ruoli agli oggetti (inventario, procedure, rischi). Obiettivo: compliance *vissuta e dimostrabile in audit* (chi è responsabile di cosa, con quali competenze, con quali scadenze). @@ -31,7 +35,7 @@ A4 trasforma i moduli oggi "a silos" in un **grafo relazionale** con al centro l - **`raci_assignments`** — **MATRICE RACI** (hub): `id, organization_id, role_id → org_roles, object_type ENUM('inventory','procedure','risk','supplier'), object_id, raci ENUM('R','A','C','I')`. → lega l'organigramma a inventario/procedure/rischi/fornitori (m2m via questa tabella). - **link m2m diretti** (richiesti dalle segnalazioni 4/5/7): `procedure_inventory(procedure_id, asset_id)`, `procedure_risk(procedure_id, risk_id)`, `inventory_risk(asset_id, risk_id)`, `risk_measure(risk_id, measure_code)` (le misure ACN dell'assessment). - **`review_schedule`** — **SCADENZIARIO centralizzato**: `id, organization_id, entity_type ENUM('role','skill','inventory','procedure','risk','supplier','measure'), entity_id, owner_role_id NULL, frequency_months, last_reviewed_at, next_review_date, status ENUM('ok','due','overdue'), notes`. Aggrega/sostituisce le scadenze sparse. -- **`actions`** (oppure riuso `corrective_actions`): azione generata da **alert** (gap competenza, scadenza, rischio) → `source_type, source_id, assigned_role_id, due_date, status`. *(Riuso consigliato di `corrective_actions` + un campo `source_type` per non duplicare il workflow CAPA.)* +- **azione da alert** → **riuso `non_conformities` + `capa_actions`** (mig.004): l'alert (gap competenza, scadenza, rischio) crea una NCR ancorata via `source_entity_type`/`source_entity_id` (es. `'competence_gap'` + `role_skills.id`) e una `capa_actions` figlia. *(NON esiste `corrective_actions`/`source_type`: si riusa l'intero workflow CAPA senza alterarne lo schema.)* **Relazioni chiave** (tutte m2m dove indicato dalle segnalazioni): ``` @@ -41,11 +45,11 @@ skills ──< skill_course_map >── training_courses procedures ──< procedure_inventory >── inventory ──< inventory_risk >── risks procedures ──< procedure_risk >── risks ──< risk_measure >── misure ACN (assessment) (role|skill|inventory|procedure|risk|supplier) ──1:N── review_schedule -gap competenze / scadenze / rischi ──trigger──> actions(corrective_actions) +gap competenze / scadenze / rischi ──trigger──> non_conformities ──< capa_actions (workflow CAPA) ``` ## 3. Gap competenze (segnalazione 3) -**Gap = `role_skills.required_level` (del ruolo che l'utente ricopre) − `user_skills.level`** per ciascuna skill richiesta. Vista `competence_gap` (calcolata): per ogni utente con `org_roles.holder_user_id`, confronto richiesto vs posseduto. **Alert** quando gap > 0 → apertura **azione** (`corrective_actions`, `source_type='competence_gap'`) + voce nello **`review_schedule`**. Ancoraggio: **PR.AT-01** (tutti), **PR.AT-02** (solo essenziali), **GV.RR-04** (cyber nelle pratiche HR). +**Gap = `role_skills.required_level` (del ruolo che l'utente ricopre) − `user_skills.level`** per ciascuna skill richiesta. Vista `competence_gap` (calcolata): per ogni utente con `org_roles.holder_user_id`, confronto richiesto vs posseduto. **Alert** quando gap > 0 → apertura **azione** = NCR (`non_conformities`, `source_entity_type='competence_gap'`, `source_entity_id=role_skills.id`) + `capa_actions` figlia (+ in futuro voce nello **`review_schedule`**). Ancoraggio: **PR.AT-01** (tutti), **PR.AT-02** (solo essenziali), **GV.RR-04** (cyber nelle pratiche HR). ## 4. Ancoraggio normativo per entità (must-have) - **Organigramma/ruoli** → **GV.RR-02** (ruoli, responsabilità e poteri stabiliti/comunicati) + **Art. 23 D.Lgs. 138/2024** (responsabilità organi di amministrazione e direttivi) → **`is_governance_body`** obbligatorio come nodo distinto con potere di approvazione. diff --git a/docs/sql/042_competences.sql b/docs/sql/042_competences.sql new file mode 100644 index 0000000..4164167 --- /dev/null +++ b/docs/sql/042_competences.sql @@ -0,0 +1,92 @@ +-- ============================================================================ +-- Migration 042 — A4 Fase 4.2: Competenze (skill / requisiti ruolo / competenze +-- possedute / mappatura corsi) + base per gap competenze e alert->azione. +-- ---------------------------------------------------------------------------- +-- 4 tabelle ADDITIVE. Idempotente (CREATE TABLE IF NOT EXISTS). Applicare con la +-- STESSA connessione PDO dell'app (host MySQL servito da php-fpm), via runner. +-- +-- Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md sez.3/4): PR.AT-01 (tutti), +-- PR.AT-02 (solo soggetti essenziali), GV.RR-04 (cyber nelle pratiche HR). +-- +-- NOTA architetturale: l'alert "gap competenza -> azione" RIUSA il workflow +-- NCR/CAPA gia esistente (tabelle non_conformities + capa_actions, mig.004). +-- NON esiste alcuna tabella 'corrective_actions' (nome errato nel design): le +-- azioni sono capa_actions, figlie obbligatorie di una non_conformity. Quindi +-- questa migration NON crea/altera tabelle azioni: ancora il gap alla NCR via +-- non_conformities.source_entity_type='competence_gap' + source_entity_id. +-- ============================================================================ + +-- Catalogo competenze. organization_id NULL = competenza globale di sistema +-- (visibile a tutte le org, come i corsi globali). +CREATE TABLE IF NOT EXISTS skills ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NULL, + name VARCHAR(150) NOT NULL, + area VARCHAR(100) NULL, + description TEXT NULL, + created_by INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_skills_org (organization_id), + INDEX idx_skills_area (area) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Competenze RICHIESTE da un ruolo dell'organigramma (m2m ruolo<->skill). +CREATE TABLE IF NOT EXISTS role_skills ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + role_id INT NOT NULL, + skill_id INT NOT NULL, + required_level TINYINT NOT NULL DEFAULT 3, + created_by INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES org_roles(id) ON DELETE CASCADE, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uk_role_skill (role_id, skill_id), + INDEX idx_role_skills_org (organization_id), + INDEX idx_role_skills_skill (skill_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Competenze POSSEDUTE da un utente (m2m utente<->skill). acquired_via_course_id +-- collega eventualmente la competenza al corso che l'ha erogata (registro corsi). +CREATE TABLE IF NOT EXISTS user_skills ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + user_id INT NOT NULL, + skill_id INT NOT NULL, + level TINYINT NOT NULL DEFAULT 1, + acquired_via_course_id INT NULL, + evidence VARCHAR(500) NULL, + assessed_at DATETIME NULL, + created_by INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, + FOREIGN KEY (acquired_via_course_id) REFERENCES training_courses(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uk_user_skill (organization_id, user_id, skill_id), + INDEX idx_user_skills_user (user_id), + INDEX idx_user_skills_skill (skill_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Mappatura competenza <-> corso che la eroga (m2m). Il corso puo essere globale +-- (training_courses.organization_id NULL) o dell'org. +CREATE TABLE IF NOT EXISTS skill_course_map ( + id INT AUTO_INCREMENT PRIMARY KEY, + skill_id INT NOT NULL, + training_course_id INT NOT NULL, + created_by INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, + FOREIGN KEY (training_course_id) REFERENCES training_courses(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uk_skill_course (skill_id, training_course_id), + INDEX idx_skill_course_course (training_course_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html index 3ff476b..f133235 100644 --- a/public/_app-bi-demo.html +++ b/public/_app-bi-demo.html @@ -70,9 +70,9 @@ - - - + + + - + + @@ -163,9 +163,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -328,8 +328,8 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - + + - + + @@ -158,8 +158,8 @@ bootstrap.loadFonts('../vendor/bootstrap-italia/dist/fonts'); } - - + + - + + @@ -180,8 +180,8 @@ bootstrap.loadFonts('../vendor/bootstrap-italia/dist/fonts'); } - - + + - - - + + + + - + + @@ -165,9 +165,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -377,9 +377,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + + + +
+