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(); $pagination = $this->getPagination(); $page = $pagination['page']; $perPage = $pagination['per_page']; $offset = $pagination['offset']; // 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), ]); } }