nis2-agile/application/controllers/NonConformityController.php
Cristiano Benassati 4e3408e9f6 [FEAT] Visura auto-fill, adesione volontaria, modulo NCR/CAPA
1. Fix auto-fill visura: mapping corretto suggested_sector e employees_range,
   indicatori visivi verdi sui campi auto-compilati, fatturato sempre manuale
2. Adesione volontaria: colonna voluntary_compliance, checkbox in onboarding
   step 5 quando not_applicable, toggle in settings, reset su ri-classificazione
3. Modulo NCR/CAPA: NonConformityController con 10 endpoint API,
   tabelle non_conformities + capa_actions, generazione NCR dai gap assessment,
   predisposizione integrazione SistemiG.agile (webhook + sync)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 08:12:57 +01:00

503 lines
19 KiB
PHP

<?php
/**
* NIS2 Agile - Non-Conformity & CAPA Controller
*
* Gestione non conformità e azioni correttive/preventive.
* Predisposizione integrazione SistemiG.agile.
*/
require_once __DIR__ . '/BaseController.php';
class NonConformityController extends BaseController
{
// ═══════════════════════════════════════════════════════════════════════
// NCR CRUD
// ═══════════════════════════════════════════════════════════════════════
/**
* POST /api/ncr/create
*/
public function create(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$this->validateRequired(['title', 'source']);
$orgId = $this->getCurrentOrgId();
$ncrCode = $this->generateCode('NCR');
$ncrId = Database::insert('non_conformities', [
'organization_id' => $orgId,
'ncr_code' => $ncrCode,
'title' => trim($this->getParam('title')),
'description' => $this->getParam('description'),
'source' => $this->getParam('source'),
'source_entity_type' => $this->getParam('source_entity_type'),
'source_entity_id' => $this->getParam('source_entity_id'),
'severity' => $this->getParam('severity', 'minor'),
'category' => $this->getParam('category'),
'nis2_article' => $this->getParam('nis2_article'),
'identified_by' => $this->getCurrentUserId(),
'assigned_to' => $this->getParam('assigned_to'),
'target_close_date' => $this->getParam('target_close_date'),
]);
$this->logAudit('ncr_created', 'non_conformity', $ncrId, [
'ncr_code' => $ncrCode,
'source' => $this->getParam('source'),
]);
$this->jsonSuccess([
'id' => $ncrId,
'ncr_code' => $ncrCode,
], 'Non conformita\' creata', 201);
}
/**
* GET /api/ncr/list
*/
public function list(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
[$page, $perPage] = $this->getPagination();
$offset = ($page - 1) * $perPage;
// Filters
$where = 'n.organization_id = ?';
$params = [$orgId];
$status = $this->getParam('status');
if ($status) {
$where .= ' AND n.status = ?';
$params[] = $status;
}
$severity = $this->getParam('severity');
if ($severity) {
$where .= ' AND n.severity = ?';
$params[] = $severity;
}
$source = $this->getParam('source');
if ($source) {
$where .= ' AND n.source = ?';
$params[] = $source;
}
$total = Database::count('non_conformities n', $where, $params);
$ncrs = Database::fetchAll(
"SELECT n.*, u1.full_name as identified_by_name, u2.full_name as assigned_to_name,
(SELECT COUNT(*) FROM capa_actions WHERE ncr_id = n.id) as capa_count
FROM non_conformities n
LEFT JOIN users u1 ON u1.id = n.identified_by
LEFT JOIN users u2 ON u2.id = n.assigned_to
WHERE {$where}
ORDER BY n.created_at DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
$this->jsonPaginated($ncrs, $total, $page, $perPage);
}
/**
* GET /api/ncr/{id}
*/
public function get(int $id): void
{
$this->requireOrgAccess();
$ncr = Database::fetchOne(
'SELECT n.*, u1.full_name as identified_by_name, u2.full_name as assigned_to_name
FROM non_conformities n
LEFT JOIN users u1 ON u1.id = n.identified_by
LEFT JOIN users u2 ON u2.id = n.assigned_to
WHERE n.id = ? AND n.organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$ncr) {
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
}
// Load CAPA actions
$ncr['capa_actions'] = Database::fetchAll(
'SELECT ca.*, u1.full_name as responsible_name, u2.full_name as verified_by_name
FROM capa_actions ca
LEFT JOIN users u1 ON u1.id = ca.responsible_user_id
LEFT JOIN users u2 ON u2.id = ca.verified_by
WHERE ca.ncr_id = ?
ORDER BY ca.created_at ASC',
[$id]
);
$this->jsonSuccess($ncr);
}
/**
* PUT /api/ncr/{id}
*/
public function update(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$ncr = Database::fetchOne(
'SELECT id FROM non_conformities WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$ncr) {
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
}
$allowedFields = [
'title', 'description', 'severity', 'category', 'nis2_article',
'status', 'assigned_to', 'target_close_date', 'actual_close_date',
'root_cause_analysis', 'root_cause_method',
];
$updates = [];
foreach ($allowedFields as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
if (empty($updates)) {
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
}
// Auto-set close date when status becomes closed
if (isset($updates['status']) && $updates['status'] === 'closed' && !isset($updates['actual_close_date'])) {
$updates['actual_close_date'] = date('Y-m-d');
}
Database::update('non_conformities', $updates, 'id = ?', [$id]);
$this->logAudit('ncr_updated', 'non_conformity', $id, $updates);
$this->jsonSuccess($updates, 'Non conformita\' aggiornata');
}
// ═══════════════════════════════════════════════════════════════════════
// CAPA ACTIONS
// ═══════════════════════════════════════════════════════════════════════
/**
* POST /api/ncr/{id}/capa
*/
public function addCapa(int $ncrId): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$this->validateRequired(['title', 'action_type']);
$orgId = $this->getCurrentOrgId();
// Verify NCR belongs to org
$ncr = Database::fetchOne(
'SELECT id FROM non_conformities WHERE id = ? AND organization_id = ?',
[$ncrId, $orgId]
);
if (!$ncr) {
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
}
$capaCode = $this->generateCode('CAPA');
$capaId = Database::insert('capa_actions', [
'ncr_id' => $ncrId,
'organization_id' => $orgId,
'capa_code' => $capaCode,
'action_type' => $this->getParam('action_type', 'corrective'),
'title' => trim($this->getParam('title')),
'description' => $this->getParam('description'),
'responsible_user_id' => $this->getParam('responsible_user_id'),
'due_date' => $this->getParam('due_date'),
'notes' => $this->getParam('notes'),
]);
// Update NCR status to action_planned if still open/investigating
$ncrStatus = Database::fetchOne('SELECT status FROM non_conformities WHERE id = ?', [$ncrId]);
if ($ncrStatus && in_array($ncrStatus['status'], ['open', 'investigating'])) {
Database::update('non_conformities', ['status' => 'action_planned'], 'id = ?', [$ncrId]);
}
$this->logAudit('capa_created', 'capa_action', $capaId, [
'ncr_id' => $ncrId,
'capa_code' => $capaCode,
]);
$this->jsonSuccess([
'id' => $capaId,
'capa_code' => $capaCode,
], 'Azione CAPA creata', 201);
}
/**
* PUT /api/ncr/capa/{subId}
*/
public function updateCapa(int $capaId): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$capa = Database::fetchOne(
'SELECT id, ncr_id FROM capa_actions WHERE id = ? AND organization_id = ?',
[$capaId, $this->getCurrentOrgId()]
);
if (!$capa) {
$this->jsonError('Azione CAPA non trovata', 404, 'CAPA_NOT_FOUND');
}
$allowedFields = [
'title', 'description', 'action_type', 'status',
'responsible_user_id', 'due_date', 'completion_date',
'verification_date', 'verified_by', 'effectiveness_review',
'is_effective', 'notes',
];
$updates = [];
foreach ($allowedFields as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
if (empty($updates)) {
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
}
// Auto-set completion date
if (isset($updates['status']) && $updates['status'] === 'completed' && !isset($updates['completion_date'])) {
$updates['completion_date'] = date('Y-m-d');
}
Database::update('capa_actions', $updates, 'id = ?', [$capaId]);
$this->logAudit('capa_updated', 'capa_action', $capaId, $updates);
$this->jsonSuccess($updates, 'Azione CAPA aggiornata');
}
// ═══════════════════════════════════════════════════════════════════════
// BULK CREATE FROM ASSESSMENT
// ═══════════════════════════════════════════════════════════════════════
/**
* POST /api/ncr/fromAssessment
* Genera NCR dai gap (risposte not_implemented/partial) di un assessment
*/
public function fromAssessment(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['assessment_id']);
$orgId = $this->getCurrentOrgId();
$assessmentId = (int) $this->getParam('assessment_id');
// Verify assessment belongs to org
$assessment = Database::fetchOne(
'SELECT id, title FROM assessments WHERE id = ? AND organization_id = ?',
[$assessmentId, $orgId]
);
if (!$assessment) {
$this->jsonError('Assessment non trovato', 404, 'ASSESSMENT_NOT_FOUND');
}
// Check for existing NCRs from this assessment to avoid duplicates
$existingCount = Database::count(
'non_conformities',
'organization_id = ? AND source = ? AND source_entity_type = ? AND source_entity_id IN (SELECT id FROM assessment_responses WHERE assessment_id = ?)',
[$orgId, 'assessment', 'assessment_response', $assessmentId]
);
if ($existingCount > 0) {
$this->jsonError(
"Esistono gia' {$existingCount} non conformita' generate da questo assessment",
409,
'NCR_ALREADY_GENERATED'
);
}
// Find gaps
$gaps = Database::fetchAll(
'SELECT * FROM assessment_responses
WHERE assessment_id = ?
AND response_value IN ("not_implemented", "partial")
ORDER BY category, question_code',
[$assessmentId]
);
if (empty($gaps)) {
$this->jsonSuccess(['created' => [], 'total' => 0], 'Nessun gap trovato nell\'assessment');
return;
}
$created = [];
$assessmentTitle = $assessment['title'];
foreach ($gaps as $gap) {
$severity = $gap['response_value'] === 'not_implemented' ? 'major' : 'minor';
$ncrCode = $this->generateCode('NCR');
$questionText = $gap['question_text'] ?? '';
$title = mb_strlen($questionText) > 200
? 'Gap: ' . mb_substr($questionText, 0, 197) . '...'
: 'Gap: ' . $questionText;
$ncrId = Database::insert('non_conformities', [
'organization_id' => $orgId,
'ncr_code' => $ncrCode,
'title' => $title,
'description' => "Gap identificato nell'assessment \"{$assessmentTitle}\": {$questionText}",
'source' => 'assessment',
'source_entity_type' => 'assessment_response',
'source_entity_id' => $gap['id'],
'severity' => $severity,
'category' => $gap['category'] ?? null,
'nis2_article' => $gap['nis2_article'] ?? null,
'identified_by' => $this->getCurrentUserId(),
'status' => 'open',
]);
$created[] = [
'id' => $ncrId,
'ncr_code' => $ncrCode,
'severity' => $severity,
'category' => $gap['category'] ?? null,
];
}
$this->logAudit('ncr_bulk_created', 'assessment', $assessmentId, [
'count' => count($created),
]);
$this->jsonSuccess([
'created' => $created,
'total' => count($created),
], count($created) . ' non conformita\' generate dall\'assessment');
}
// ═══════════════════════════════════════════════════════════════════════
// STATISTICS
// ═══════════════════════════════════════════════════════════════════════
/**
* GET /api/ncr/stats
*/
public function stats(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
// NCR counts by status
$byStatus = Database::fetchAll(
'SELECT status, COUNT(*) as count FROM non_conformities WHERE organization_id = ? GROUP BY status',
[$orgId]
);
// NCR counts by severity
$bySeverity = Database::fetchAll(
'SELECT severity, COUNT(*) as count FROM non_conformities WHERE organization_id = ? GROUP BY severity',
[$orgId]
);
// CAPA counts by status
$capaByStatus = Database::fetchAll(
'SELECT status, COUNT(*) as count FROM capa_actions WHERE organization_id = ? GROUP BY status',
[$orgId]
);
// Overdue NCRs
$overdueNcr = Database::count(
'non_conformities',
'organization_id = ? AND target_close_date < CURDATE() AND status NOT IN ("closed", "cancelled")',
[$orgId]
);
// Overdue CAPAs
$overdueCapa = Database::count(
'capa_actions',
'organization_id = ? AND due_date < CURDATE() AND status NOT IN ("completed", "verified")',
[$orgId]
);
$totalNcr = Database::count('non_conformities', 'organization_id = ?', [$orgId]);
$openNcr = Database::count('non_conformities', 'organization_id = ? AND status NOT IN ("closed", "cancelled")', [$orgId]);
$this->jsonSuccess([
'total_ncr' => $totalNcr,
'open_ncr' => $openNcr,
'overdue_ncr' => $overdueNcr,
'overdue_capa' => $overdueCapa,
'ncr_by_status' => $byStatus,
'ncr_by_severity' => $bySeverity,
'capa_by_status' => $capaByStatus,
]);
}
// ═══════════════════════════════════════════════════════════════════════
// EXTERNAL INTEGRATION (SistemiG.agile - Future)
// ═══════════════════════════════════════════════════════════════════════
/**
* POST /api/ncr/{id}/sync
* Sincronizza NCR con sistema esterno (SistemiG.agile)
*/
public function syncExternal(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$ncr = Database::fetchOne(
'SELECT * FROM non_conformities WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$ncr) {
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
}
$targetUrl = defined('SISTEMIG_API_URL') ? SISTEMIG_API_URL : '';
if (empty($targetUrl)) {
$this->jsonError('Integrazione SistemiG non configurata', 501, 'INTEGRATION_NOT_CONFIGURED');
}
// Placeholder: in future this will POST to SistemiG API
$this->jsonSuccess([
'ncr_id' => $id,
'ncr_code' => $ncr['ncr_code'],
'sync_status' => 'not_implemented',
'message' => 'Integrazione SistemiG.agile in fase di sviluppo',
]);
}
/**
* POST /api/ncr/webhook
* Webhook per ricezione aggiornamenti da sistema esterno
* Autenticato via API key (X-SistemiG-Key header), non JWT
*/
public function webhook(): void
{
// Authenticate via API key instead of JWT
$apiKey = $_SERVER['HTTP_X_SISTEMIG_KEY'] ?? '';
$expectedKey = defined('SISTEMIG_API_KEY') ? SISTEMIG_API_KEY : '';
if (empty($expectedKey) || $apiKey !== $expectedKey) {
$this->jsonError('Chiave API non valida', 401, 'INVALID_API_KEY');
}
// Placeholder: process incoming webhook payload
$payload = $this->getJsonBody();
$this->jsonSuccess([
'received' => true,
'message' => 'Webhook ricevuto. Integrazione SistemiG.agile in fase di sviluppo.',
'payload_keys' => array_keys($payload),
]);
}
}