[FEAT] A4 Fase 4.2 — Competenze (skill/requisiti ruolo/gap) + alert→azione NCR+CAPA

Secondo modulo del modello relazionale A4 (docs/DESIGN_A4_RELATIONAL.md).

Backend:
- Migration 042 (docs/sql/042_competences.sql) + runner aggiornato (scripts/migrate-a4.php).
  4 tabelle additive/idempotenti: skills (org-owned o globali), role_skills
  (competenze richieste dal ruolo, UNIQUE role+skill), user_skills (possedute,
  UNIQUE org+user+skill), skill_course_map. APPLICATA su prod (nis2-db v8.0.45,
  TLSv1.3).
- CompetenceController (route 'competences'): catalog, skills CRUD, roleSkills,
  userSkills, skillCourses (map/unmap), gapGrid (richiesto−posseduto per ruolo
  con titolare), openAction. Multi-tenancy + anti-IDOR + livelli 1-5; competenze
  globali in sola lettura per gli utenti org.
- alert→azione: openAction RIUSA il workflow NCR/CAPA reale (la tabella azioni è
  capa_actions figlia di non_conformities, NON 'corrective_actions' che non
  esiste): crea NCR (source='management_review', source_entity_type='competence_gap',
  source_entity_id=role_skills.id) + capa_actions figlia. Anti-duplicato sul gap.
  Zero ALTER alle tabelle esistenti. Ancoraggio PR.AT-01/02, GV.RR-04, art.24 D.Lgs.

Frontend:
- competenze.html (4 schede: Catalogo, Requisiti per ruolo, Competenze persone,
  Gap & azioni) + js/competenze.js. Bootstrap Italia V2. CTA "Assegna corso"
  (riuso /training/assign) e "Apri non conformità".
- Voce sidebar "Competenze" (common.js + common-bi.js + BI_PAGES) + nav.competences
  i18n IT/EN. api.js: metodi comp* + assignTraining.

Help/KB/PWA:
- help.js: guida contestuale 'competences' (schede, calcolo gap, PR.AT-01/02,
  GV.RR-04, art.24 D.Lgs., disclaimer no-parere-legale) + mappa pagina.
- sw.js: nome cache nis2-shell-v1.17.0 (allineamento PWA).
- Design doc corretto (rettifica capa_actions vs corrective_actions) + avanzamento 4.1/4.2.

Cache-buster: ?v=20260618 dei 5 JS condivisi su 32 HTML. version.json 1.16.0 -> 1.17.0.
Smoke E2E su prod (fpm reale): catalogo, requisito, competenza, gap=2, openAction
(NCR+CAPA), anti-dup 409, mappatura corso — tutti verdi; dati di test ripuliti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-15 19:01:32 +02:00
co-authored by Claude Opus 4.8
parent 47199f1e4e
commit 5368b0a61c
47 changed files with 1594 additions and 143 deletions
@@ -0,0 +1,700 @@
<?php
/**
* NIS2 Agile - Competenze (A4 Fase 4.2)
* ----------------------------------------------------------------------------
* Catalogo competenze (skills), requisiti per ruolo (role_skills), competenze
* possedute dagli utenti (user_skills), mappatura competenza<->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,
];
}
}